/*******************************************************************************
 * Copyright (c) 2006, 2010 Steffen Pingel and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     Steffen Pingel - initial API and implementation
 *******************************************************************************/

package org.eclipse.mylyn.internal.trac.core.client;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StreamTokenizer;
import java.io.StringReader;
import java.net.HttpURLConnection;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;

import javax.swing.text.html.HTML.Tag;

import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HostConfiguration;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.lang.StringEscapeUtils;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.mylyn.commons.core.HtmlStreamTokenizer;
import org.eclipse.mylyn.commons.core.HtmlStreamTokenizer.Token;
import org.eclipse.mylyn.commons.core.HtmlTag;
import org.eclipse.mylyn.commons.net.AbstractWebLocation;
import org.eclipse.mylyn.commons.net.AuthenticationCredentials;
import org.eclipse.mylyn.commons.net.AuthenticationType;
import org.eclipse.mylyn.commons.net.Policy;
import org.eclipse.mylyn.commons.net.SslCertificateException;
import org.eclipse.mylyn.commons.net.UnsupportedRequestException;
import org.eclipse.mylyn.commons.net.WebUtil;
import org.eclipse.mylyn.internal.trac.core.model.TracComment;
import org.eclipse.mylyn.internal.trac.core.model.TracComponent;
import org.eclipse.mylyn.internal.trac.core.model.TracMilestone;
import org.eclipse.mylyn.internal.trac.core.model.TracPriority;
import org.eclipse.mylyn.internal.trac.core.model.TracRepositoryInfo;
import org.eclipse.mylyn.internal.trac.core.model.TracSearch;
import org.eclipse.mylyn.internal.trac.core.model.TracSearchFilter;
import org.eclipse.mylyn.internal.trac.core.model.TracSearchFilter.CompareOperator;
import org.eclipse.mylyn.internal.trac.core.model.TracSeverity;
import org.eclipse.mylyn.internal.trac.core.model.TracTicket;
import org.eclipse.mylyn.internal.trac.core.model.TracTicket.Key;
import org.eclipse.mylyn.internal.trac.core.model.TracTicketResolution;
import org.eclipse.mylyn.internal.trac.core.model.TracTicketStatus;
import org.eclipse.mylyn.internal.trac.core.model.TracTicketType;
import org.eclipse.mylyn.internal.trac.core.model.TracVersion;
import org.eclipse.mylyn.internal.trac.core.util.TracHttpClientTransportFactory.TracHttpException;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;

/**
 * Represents a Trac repository that is accessed through the Trac's query script and web interface.
 * 
 * @author Steffen Pingel
 */
public class TracWebClient extends AbstractTracClient {

	private interface AttributeFactory {

		void initialize();

		void addAttribute(String value);

	}

	private static class TracConfiguration {

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

		public TracConfiguration(final TracClientData data) {
			AttributeFactory attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.components.add(new TracComponent(value));
				}

				public void initialize() {
					data.components = new ArrayList<TracComponent>();
				}
			};
			factoryByField.put("component", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.milestones.add(new TracMilestone(value));
				}

				public void initialize() {
					data.milestones = new ArrayList<TracMilestone>();
				}
			};
			factoryByField.put("milestone", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.priorities.add(new TracPriority(value, data.priorities.size() + 1));
				}

				public void initialize() {
					data.priorities = new ArrayList<TracPriority>();
				}
			};
			factoryByField.put("priority", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.ticketResolutions.add(new TracTicketResolution(value, data.ticketResolutions.size() + 1));
				}

				public void initialize() {
					data.ticketResolutions = new ArrayList<TracTicketResolution>();
				}
			};
			factoryByField.put("resolution", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.severities.add(new TracSeverity(value, data.severities.size() + 1));
				}

				public void initialize() {
					data.severities = new ArrayList<TracSeverity>();
				}
			};
			factoryByField.put("severity", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.ticketStatus.add(new TracTicketStatus(value, data.ticketStatus.size() + 1));
				}

				public void initialize() {
					data.ticketStatus = new ArrayList<TracTicketStatus>();
				}
			};
			factoryByField.put("status", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.ticketTypes.add(new TracTicketType(value, data.ticketTypes.size() + 1));
				}

				public void initialize() {
					data.ticketTypes = new ArrayList<TracTicketType>();
				}
			};
			factoryByField.put("type", attributeFactory); //$NON-NLS-1$

			attributeFactory = new AttributeFactory() {
				public void addAttribute(String value) {
					data.versions.add(new TracVersion(value));
				}

				public void initialize() {
					data.versions = new ArrayList<TracVersion>();
				}
			};
			factoryByField.put("version", attributeFactory); //$NON-NLS-1$
		}

		public AttributeFactory getFactoryByField(String field) {
			return factoryByField.get(field);
		}

	}

	private static class TracConfigurationField {

		@SuppressWarnings("unused")
		String label;

		@SuppressWarnings("unused")
		String type;

		List<String> options;

		List<TracConfigurationOptGroup> optgroups;

	}

	private static class TracConfigurationOptGroup {

		@SuppressWarnings("unused")
		String label;

		List<String> options;

	}

	private class Request {

		private final String url;

		private HostConfiguration hostConfiguration;

		public Request(String url) {
			this.url = url;
		}

		public GetMethod execute(IProgressMonitor monitor) throws TracLoginException, IOException, TracHttpException {
			hostConfiguration = WebUtil.createHostConfiguration(httpClient, location, monitor);

			for (int attempt = 0; attempt < 2; attempt++) {
				// force authentication
				if (!authenticated) {
					AuthenticationCredentials credentials = location.getCredentials(AuthenticationType.REPOSITORY);
					if (credentialsValid(credentials)) {
						try {
							authenticate(monitor);
						} catch (TracLoginException e) {
							// re-try once, see bug 302792							
							authenticate(monitor);
						}
					}
				}

				GetMethod method = new GetMethod(WebUtil.getRequestPath(url));
				int code;
				try {
					code = WebUtil.execute(httpClient, hostConfiguration, method, monitor);
				} catch (IOException e) {
					WebUtil.releaseConnection(method, monitor);
					throw e;
				} catch (RuntimeException e) {
					WebUtil.releaseConnection(method, monitor);
					throw e;
				}

				if (code == HttpURLConnection.HTTP_OK) {
					return method;
				} else {
					WebUtil.releaseConnection(method, monitor);
					if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) {
						// login or re-authenticate due to an expired session
						authenticated = false;
						authenticate(monitor);
					} else {
						throw new TracHttpException(code);
					}
				}
			}

			throw new TracLoginException();
		}

		private void authenticate(IProgressMonitor monitor) throws TracLoginException, IOException {
			while (true) {
				AuthenticationCredentials credentials = location.getCredentials(AuthenticationType.REPOSITORY);
				if (!credentialsValid(credentials)) {
					throw new TracLoginException();
				}

				// try standard basic/digest/ntlm authentication first
				AuthScope authScope = new AuthScope(WebUtil.getHost(repositoryUrl), WebUtil.getPort(repositoryUrl),
						null, AuthScope.ANY_SCHEME);
				Credentials httpCredentials = WebUtil.getHttpClientCredentials(credentials,
						WebUtil.getHost(repositoryUrl));
				httpClient.getState().setCredentials(authScope, httpCredentials);
//				if (CoreUtil.TEST_MODE) {
//					System.err.println(" Setting credentials: " + httpCredentials); //$NON-NLS-1$
//				}

				GetMethod method = new GetMethod(WebUtil.getRequestPath(repositoryUrl + LOGIN_URL));
				method.setFollowRedirects(false);
				int code;
				try {
					code = WebUtil.execute(httpClient, hostConfiguration, method, monitor);
					if (needsReauthentication(code, monitor)) {
						continue;
					}
				} catch (SslCertificateException e) {
					if (needsReauthentication(SC_CERT_AUTH_FAILED, monitor)) {
						continue;
					}
					throw e;
				} finally {
					WebUtil.releaseConnection(method, monitor);
				}

				// the expected return code is a redirect, anything else is suspicious
				if (code == HttpURLConnection.HTTP_OK) {
					// try form-based authentication via AccountManagerPlugin as a
					// fall-back
					authenticateAccountManager(httpClient, hostConfiguration, credentials, monitor);
				}

				validateAuthenticationState(httpClient);

				// success since no exception was thrown
				authenticated = true;
				break;
			}
		}

		private boolean needsReauthentication(int code, IProgressMonitor monitor) throws IOException,
				TracLoginException {
			final AuthenticationType authenticationType;
			if (code == HttpStatus.SC_UNAUTHORIZED || code == HttpStatus.SC_FORBIDDEN) {
				authenticationType = AuthenticationType.REPOSITORY;
			} else if (code == HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED) {
				authenticationType = AuthenticationType.PROXY;
			} else if (code == SC_CERT_AUTH_FAILED) {
				authenticationType = AuthenticationType.CERTIFICATE;
			} else {
				return false;
			}

			try {
				location.requestCredentials(authenticationType, null, monitor);
			} catch (UnsupportedRequestException e) {
				throw new TracLoginException();
			}

			hostConfiguration = WebUtil.createHostConfiguration(httpClient, location, monitor);
			return true;
		}

	}

	private final HttpClient httpClient;

	private boolean authenticated;

	public TracWebClient(AbstractWebLocation location, Version version) {
		super(location, version);
		this.httpClient = createHttpClient();
	}

	private synchronized GetMethod connect(String requestUrl, IProgressMonitor monitor) throws TracException {
		monitor = Policy.monitorFor(monitor);
		try {
			Request request = new Request(requestUrl);
			return request.execute(monitor);
		} catch (TracException e) {
			throw e;
		} catch (Exception e) {
			throw new TracException(e);
		}
	}

	/**
	 * Fetches the web site of a single ticket and returns the Trac ticket.
	 * 
	 * @param id
	 *            Trac id of ticket
	 */
	public TracTicket getTicket(int id, IProgressMonitor monitor) throws TracException {
		GetMethod method = connect(repositoryUrl + ITracClient.TICKET_URL + id, monitor);
		try {
			TracTicket ticket = new TracTicket(id);

			InputStream in = WebUtil.getResponseBodyAsStream(method, monitor);
			try {
				BufferedReader reader = new BufferedReader(new InputStreamReader(in, method.getResponseCharSet()));
				HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
				for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
					if (token.getType() == Token.TAG) {
						HtmlTag tag = (HtmlTag) token.getValue();
						if (tag.getTagType() == Tag.TD) {
							String headers = tag.getAttribute("headers"); //$NON-NLS-1$
							if ("h_component".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.COMPONENT, getText(tokenizer));
							} else if ("h_milestone".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.MILESTONE, getText(tokenizer));
							} else if ("h_priority".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.PRIORITY, getText(tokenizer));
							} else if ("h_severity".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.SEVERITY, getText(tokenizer));
							} else if ("h_version".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.VERSION, getText(tokenizer));
							} else if ("h_keywords".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.KEYWORDS, getText(tokenizer));
							} else if ("h_cc".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.CC, getText(tokenizer));
							} else if ("h_owner".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.OWNER, getText(tokenizer));
							} else if ("h_reporter".equals(headers)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.REPORTER, getText(tokenizer));
							}
							// TODO handle custom fields
						} else if ((tag.getTagType() == Tag.H2 && ("summary".equals(tag.getAttribute("class")) || "summary searchable".equals(tag.getAttribute("class")))) //$NON-NLS-1$ //$NON-NLS-2$  //$NON-NLS-3$ //$NON-NLS-4$
								|| tag.getTagType() == Tag.SPAN && ("summary".equals(tag.getAttribute("class")))) { //$NON-NLS-1$ //$NON-NLS-2$ 
							ticket.putBuiltinValue(Key.SUMMARY, getText(tokenizer));
						} else if (tag.getTagType() == Tag.H3 && "status".equals(tag.getAttribute("class"))) { //$NON-NLS-1$ //$NON-NLS-2$
							String text = getStrongText(tokenizer);
							if (text.length() > 0) {
								// Trac 0.9 format: status / status (resolution)
								int i = text.indexOf(" ("); //$NON-NLS-1$
								if (i != -1) {
									ticket.putBuiltinValue(Key.STATUS, text.substring(0, i));
									ticket.putBuiltinValue(Key.RESOLUTION, text.substring(i + 2, text.length() - 1));
								} else {
									ticket.putBuiltinValue(Key.STATUS, text);
								}
							}
						} else if (tag.getTagType() == Tag.SPAN) {
							String clazz = tag.getAttribute("class"); //$NON-NLS-1$
							if ("status".equals(clazz)) { //$NON-NLS-1$
								// Trac 0.10 format: (status type) / (status type: resolution)
								String text = getText(tokenizer);
								if (text.startsWith("(") && text.endsWith(")")) { //$NON-NLS-1$ //$NON-NLS-2$
									StringTokenizer t = new StringTokenizer(text.substring(1, text.length() - 1), " :"); //$NON-NLS-1$
									if (t.hasMoreTokens()) {
										ticket.putBuiltinValue(Key.STATUS, t.nextToken());
									}
									if (t.hasMoreTokens()) {
										ticket.putBuiltinValue(Key.TYPE, t.nextToken());
									}
									if (t.hasMoreTokens()) {
										ticket.putBuiltinValue(Key.RESOLUTION, t.nextToken());
									}
								}
							} else if ("trac-status".equals(clazz)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.STATUS, getText(tokenizer));
							} else if ("trac-type".equals(clazz)) { //$NON-NLS-1$
								ticket.putBuiltinValue(Key.TYPE, getText(tokenizer));
							} else if ("trac-resolution".equals(clazz)) { //$NON-NLS-1$
								String text = getText(tokenizer);
								if (text.startsWith("(") && text.endsWith(")")) { //$NON-NLS-1$ //$NON-NLS-2$
									ticket.putBuiltinValue(Key.RESOLUTION, text.substring(1, text.length() - 1).trim());
								} else {
									ticket.putBuiltinValue(Key.RESOLUTION, text);
								}
							}

						}
						// TODO parse description
					}
				}
			} finally {
				in.close();
			}

			if (ticket.isValid() && ticket.getValue(Key.SUMMARY) != null) {
				return ticket;
			}

			throw new InvalidTicketException();
		} catch (IOException e) {
			throw new TracException(e);
		} catch (ParseException e) {
			throw new TracException(e);
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	public void searchForTicketIds(TracSearch query, List<Integer> result, IProgressMonitor monitor)
			throws TracException {
		List<TracTicket> ticketResult = new ArrayList<TracTicket>();
		search(query, ticketResult, monitor);
		for (TracTicket tracTicket : ticketResult) {
			result.add(tracTicket.getId());
		}
	}

	public void search(TracSearch query, List<TracTicket> result, IProgressMonitor monitor) throws TracException {
		GetMethod method = connect(repositoryUrl + ITracClient.QUERY_URL + query.toUrl(), monitor);
		try {
			InputStream in = WebUtil.getResponseBodyAsStream(method, monitor);
			try {
				BufferedReader reader = new BufferedReader(new InputStreamReader(in, method.getResponseCharSet()));

				WebSearchResultParser parser = new WebSearchResultParser();
				parser.parse(reader);

				Map<String, String> constantValues = getExactMatchValues(query);
				for (TracTicket ticket : parser.getTickets()) {
					if (ticket.isValid()) {
						for (String key : constantValues.keySet()) {
							ticket.putValue(key, WebSearchResultParser.parseTicketValue(constantValues.get(key)));
						}
						result.add(ticket);
					}
				}
			} finally {
				in.close();
			}
		} catch (IOException e) {
			throw new TracException(e);
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	/**
	 * Extracts constant values from <code>query</code>. The Trac query script does not return fields that matched
	 * exactly againt a single value.
	 */
	private Map<String, String> getExactMatchValues(TracSearch query) {
		Map<String, String> values = new HashMap<String, String>();
		List<TracSearchFilter> filters = query.getFilters();
		for (TracSearchFilter filter : filters) {
			if (filter.getOperator() == CompareOperator.IS && filter.getValues().size() == 1) {
				values.put(filter.getFieldName(), filter.getValues().get(0));
			}
		}
		return values;
	}

	public TracRepositoryInfo validate(IProgressMonitor monitor) throws TracException {
		GetMethod method = connect(repositoryUrl + "/", monitor); //$NON-NLS-1$
		try {
			return new TracRepositoryInfo();
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	@Override
	public void updateAttributes(IProgressMonitor monitor) throws TracException {
		monitor.beginTask(Messages.TracWebClient_Updating_attributes, IProgressMonitor.UNKNOWN);

		GetMethod method = connect(repositoryUrl + ITracClient.CUSTOM_QUERY_URL, monitor);
		try {
			InputStream in = WebUtil.getResponseBodyAsStream(method, monitor);
			try {
				BufferedReader reader = new BufferedReader(new InputStreamReader(in, method.getResponseCharSet()));
				HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
				for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
					if (monitor.isCanceled()) {
						throw new OperationCanceledException();
					}

					if (token.getType() == Token.TAG) {
						HtmlTag tag = (HtmlTag) token.getValue();
						if (tag.getTagType() == Tag.SCRIPT) {
							String text = getText(tokenizer).trim();
							int i = text.indexOf("var properties="); //$NON-NLS-1$
							if (i != -1) {
								if (!parseAttributesJSon(text.substring(i))) {
									// fall back
									parseAttributesTokenizer(text.substring(i));
								}
							}
						}
					}
				}

				addResolutionAndStatus();
			} finally {
				in.close();
			}
		} catch (IOException e) {
			throw new TracException(e);
		} catch (ParseException e) {
			throw new TracException(e);
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	enum AttributeState {
		INIT, IN_LIST, IN_ATTRIBUTE_KEY, IN_ATTRIBUTE_VALUE, IN_ATTRIBUTE_VALUE_LIST
	};

	private boolean parseAttributesJSon(String text) {
		// remove surrounding JavaScript
		if (text.startsWith("var properties=")) { //$NON-NLS-1$
			text = text.substring("var properties=".length()); //$NON-NLS-1$
		}
		int i = text.indexOf("};"); //$NON-NLS-1$
		if (i != -1) {
			text = text.substring(0, i + 1);
		}

		// parse JSon stream
		GsonBuilder builder = new GsonBuilder();
		Gson gson = builder.create();
		TypeToken<Map<String, TracConfigurationField>> type = new TypeToken<Map<String, TracConfigurationField>>() {
		};
		Map<String, TracConfigurationField> fieldByName;
		try {
			fieldByName = gson.fromJson(text, type.getType());
			if (fieldByName == null) {
				return false;
			}
		} catch (JsonSyntaxException e) {
			return false;
		}

		// copy parsed JSon objects in to client data 
		TracConfiguration configuration = new TracConfiguration(data);
		for (Map.Entry<String, TracConfigurationField> entry : fieldByName.entrySet()) {
			AttributeFactory factory = configuration.getFactoryByField(entry.getKey());
			if (factory != null) {
				factory.initialize();

				TracConfigurationField field = entry.getValue();
				if (field.options != null && field.options.size() > 0) {
					for (String option : field.options) {
						factory.addAttribute(option);
					}
				} else if (field.optgroups != null && field.optgroups.size() > 0) {
					// milestones in Trac 0.13 support groups for labeling related options: ignore groups but extract options  
					for (TracConfigurationOptGroup group : field.optgroups) {
						if (group.options != null) {
							for (String option : group.options) {
								factory.addAttribute(option);
							}
						}
					}
				}
			}
		}
		return true;
	}

	/**
	 * Parses the JavaScript code from the query page to extract repository configuration.
	 */
	private void parseAttributesTokenizer(String text) throws IOException {
		StreamTokenizer t = new StreamTokenizer(new StringReader(text));
		t.quoteChar('"');

		TracConfiguration configuration = new TracConfiguration(data);
		AttributeFactory attributeFactory = null;
		String attributeType = null;

		AttributeState state = AttributeState.INIT;
		int tokenType;
		while ((tokenType = t.nextToken()) != StreamTokenizer.TT_EOF) {
			switch (tokenType) {
			case StreamTokenizer.TT_WORD:
			case '"':
				if (state == AttributeState.IN_LIST) {
					attributeFactory = configuration.getFactoryByField(t.sval);
					if (attributeFactory != null) {
						attributeFactory.initialize();
					}
				} else if (state == AttributeState.IN_ATTRIBUTE_KEY) {
					attributeType = t.sval;
				} else if (state == AttributeState.IN_ATTRIBUTE_VALUE_LIST && "options".equals(attributeType)) { //$NON-NLS-1$
					if (attributeFactory != null) {
						attributeFactory.addAttribute(t.sval);
					}
				}
				break;
			case ':':
				if (state == AttributeState.IN_ATTRIBUTE_KEY) {
					state = AttributeState.IN_ATTRIBUTE_VALUE;
				}
				break;
			case ',':
				if (state == AttributeState.IN_ATTRIBUTE_VALUE) {
					state = AttributeState.IN_ATTRIBUTE_KEY;
				}
				break;
			case '[':
				if (state == AttributeState.IN_ATTRIBUTE_VALUE) {
					state = AttributeState.IN_ATTRIBUTE_VALUE_LIST;
				}
				break;
			case ']':
				if (state == AttributeState.IN_ATTRIBUTE_VALUE_LIST) {
					state = AttributeState.IN_ATTRIBUTE_VALUE;
				}
				break;
			case '{':
				if (state == AttributeState.INIT) {
					state = AttributeState.IN_LIST;
				} else if (state == AttributeState.IN_LIST) {
					state = AttributeState.IN_ATTRIBUTE_KEY;
				} else {
					throw new IOException("Error parsing attributes: unexpected token '{'"); //$NON-NLS-1$
				}
				break;
			case '}':
				if (state == AttributeState.IN_ATTRIBUTE_KEY || state == AttributeState.IN_ATTRIBUTE_VALUE) {
					state = AttributeState.IN_LIST;
				} else if (state == AttributeState.IN_LIST) {
					state = AttributeState.INIT;
				} else {
					throw new IOException("Error parsing attributes: unexpected token '}'"); //$NON-NLS-1$
				}
				break;
			}
		}
	}

	public void updateAttributesNewTicketPage(IProgressMonitor monitor) throws TracException {
		monitor.beginTask(Messages.TracWebClient_Updating_attributes, IProgressMonitor.UNKNOWN);

		GetMethod method = connect(repositoryUrl + ITracClient.NEW_TICKET_URL, monitor);
		try {
			InputStream in = WebUtil.getResponseBodyAsStream(method, monitor);
			try {
				BufferedReader reader = new BufferedReader(new InputStreamReader(in, method.getResponseCharSet()));
				HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
				for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
					if (monitor.isCanceled()) {
						throw new OperationCanceledException();
					}

					if (token.getType() == Token.TAG) {
						HtmlTag tag = (HtmlTag) token.getValue();
						if (tag.getTagType() == Tag.SELECT) {
							String name = tag.getAttribute("id"); //$NON-NLS-1$
							if ("component".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.components = new ArrayList<TracComponent>(values.size());
								for (String value : values) {
									data.components.add(new TracComponent(value));
								}
							} else if ("milestone".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.milestones = new ArrayList<TracMilestone>(values.size());
								for (String value : values) {
									data.milestones.add(new TracMilestone(value));
								}
							} else if ("priority".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.priorities = new ArrayList<TracPriority>(values.size());
								for (int i = 0; i < values.size(); i++) {
									data.priorities.add(new TracPriority(values.get(i), i + 1));
								}
							} else if ("severity".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.severities = new ArrayList<TracSeverity>(values.size());
								for (int i = 0; i < values.size(); i++) {
									data.severities.add(new TracSeverity(values.get(i), i + 1));
								}
							} else if ("type".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.ticketTypes = new ArrayList<TracTicketType>(values.size());
								for (int i = 0; i < values.size(); i++) {
									data.ticketTypes.add(new TracTicketType(values.get(i), i + 1));
								}
							} else if ("version".equals(name)) { //$NON-NLS-1$
								List<String> values = getOptionValues(tokenizer);
								data.versions = new ArrayList<TracVersion>(values.size());
								for (String value : values) {
									data.versions.add(new TracVersion(value));
								}
							}
						}
					}
				}

				addResolutionAndStatus();
			} finally {
				in.close();
			}
		} catch (IOException e) {
			throw new TracException(e);
		} catch (ParseException e) {
			throw new TracException(e);
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	private void addResolutionAndStatus() {
		if (data.ticketResolutions == null || data.ticketResolutions.isEmpty()) {
			data.ticketResolutions = new ArrayList<TracTicketResolution>(5);
			data.ticketResolutions.add(new TracTicketResolution("fixed", 1)); //$NON-NLS-1$
			data.ticketResolutions.add(new TracTicketResolution("invalid", 2)); //$NON-NLS-1$
			data.ticketResolutions.add(new TracTicketResolution("wontfix", 3)); //$NON-NLS-1$
			data.ticketResolutions.add(new TracTicketResolution("duplicate", 4)); //$NON-NLS-1$
			data.ticketResolutions.add(new TracTicketResolution("worksforme", 5)); //$NON-NLS-1$
		}

		if (data.ticketStatus == null || data.ticketStatus.isEmpty()) {
			data.ticketStatus = new ArrayList<TracTicketStatus>(4);
			data.ticketStatus.add(new TracTicketStatus("new", 1)); //$NON-NLS-1$
			data.ticketStatus.add(new TracTicketStatus("assigned", 2)); //$NON-NLS-1$
			data.ticketStatus.add(new TracTicketStatus("reopened", 3)); //$NON-NLS-1$
			data.ticketStatus.add(new TracTicketStatus("closed", 4)); //$NON-NLS-1$
		}
	}

	private List<String> getOptionValues(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
		List<String> values = new ArrayList<String>();
		for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
			if (token.getType() == Token.TAG) {
				HtmlTag tag = (HtmlTag) token.getValue();
				if (tag.getTagType() == Tag.OPTION && !tag.isEndTag()) {
					String value = getText(tokenizer).trim();
					if (value.length() > 0) {
						values.add(value);
					}
				} else {
					return values;
				}
			}
		}
		return values;
	}

	private String getText(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
		StringBuilder sb = new StringBuilder();
		for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
			if (token.getType() == Token.TEXT) {
				sb.append(token.toString().trim());
				sb.append(" "); //$NON-NLS-1$
			} else if (token.getType() == Token.COMMENT) {
				// ignore
			} else if (token.getType() == Token.TAG && ((HtmlTag) token.getValue()).getTagType() == Tag.A) {
				// ignore, Trac 0.11 wraps milestone values in links
			} else {
				break;
			}
		}
		return StringEscapeUtils.unescapeHtml(sb.toString().trim());
	}

	/**
	 * Looks for a <code>strong</code> tag and returns the text enclosed by the tag.
	 */
	private String getStrongText(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
		for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
			if (token.getType() == Token.TAG && ((HtmlTag) token.getValue()).getTagType() == Tag.STRONG) {
				return getText(tokenizer);
			} else if (token.getType() == Token.COMMENT) {
				// ignore
			} else if (token.getType() == Token.TEXT) {
				// ignore
			} else {
				break;
			}
		}
		return ""; //$NON-NLS-1$
	}

	public InputStream getAttachmentData(int id, String filename, IProgressMonitor monitor) throws TracException {
		GetMethod method = connect(repositoryUrl + ITracClient.ATTACHMENT_URL + id + "/" + filename + "?format=raw", //$NON-NLS-1$ //$NON-NLS-2$
				monitor);
		try {
			// the receiver is responsible for closing the stream which will
			// release the connection
			return method.getResponseBodyAsStream();
		} catch (IOException e) {
			WebUtil.releaseConnection(method, monitor);
			throw new TracException(e);
		}
	}

	public void putAttachmentData(int id, String name, String description, InputStream in, IProgressMonitor monitor,
			boolean replace) throws TracException {
		throw new TracException("Unsupported operation"); //$NON-NLS-1$
	}

	public void deleteAttachment(int ticketId, String filename, IProgressMonitor monitor) throws TracException {
		throw new TracException("Unsupported operation"); //$NON-NLS-1$
	}

	public int createTicket(TracTicket ticket, IProgressMonitor monitor) throws TracException {
		throw new TracException("Unsupported operation"); //$NON-NLS-1$
	}

	public void updateTicket(TracTicket ticket, String comment, IProgressMonitor monitor) throws TracException {
		throw new TracException("Unsupported operation"); //$NON-NLS-1$
	}

	public Set<Integer> getChangedTickets(Date since, IProgressMonitor monitor) throws TracException {
		return null;
	}

	public Date getTicketLastChanged(Integer id, IProgressMonitor monitor) {
		throw new UnsupportedOperationException();
	}

	public void deleteTicket(int ticketId, IProgressMonitor monitor) throws TracException {
		throw new UnsupportedOperationException();
	}

	public List<TracComment> getComments(int id, IProgressMonitor monitor) throws TracException {
		throw new UnsupportedOperationException();
	}

}
