/*******************************************************************************
 * 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
 *     Xiaoyang Guan - improvements
 *******************************************************************************/

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

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Pattern;

import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.HostConfiguration;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.HttpState;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.auth.AuthScheme;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.auth.BasicScheme;
import org.apache.commons.httpclient.auth.DigestScheme;
import org.apache.commons.httpclient.auth.NTLMScheme;
import org.apache.commons.httpclient.methods.HeadMethod;
import org.apache.xmlrpc.XmlRpcException;
import org.apache.xmlrpc.client.XmlRpcClient;
import org.apache.xmlrpc.client.XmlRpcClientConfigImpl;
import org.apache.xmlrpc.serializer.CharSetXmlWriterFactory;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;
import org.eclipse.mylyn.commons.core.CoreUtil;
import org.eclipse.mylyn.commons.core.StatusHandler;
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.UnsupportedRequestException;
import org.eclipse.mylyn.commons.net.WebUtil;
import org.eclipse.mylyn.internal.trac.core.TracCorePlugin;
import org.eclipse.mylyn.internal.trac.core.model.TracAction;
import org.eclipse.mylyn.internal.trac.core.model.TracAttachment;
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.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.TracTicketField;
import org.eclipse.mylyn.internal.trac.core.model.TracTicketField.Type;
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.model.TracWikiPage;
import org.eclipse.mylyn.internal.trac.core.model.TracWikiPageInfo;
import org.eclipse.mylyn.internal.trac.core.util.HttpMethodInterceptor;
import org.eclipse.mylyn.internal.trac.core.util.TracHttpClientTransportFactory;
import org.eclipse.mylyn.internal.trac.core.util.TracHttpClientTransportFactory.TracHttpException;
import org.eclipse.mylyn.internal.trac.core.util.TracUtil;
import org.eclipse.mylyn.internal.trac.core.util.TracXmlRpcClientRequest;
import org.eclipse.osgi.util.NLS;

/**
 * Represents a Trac repository that is accessed through the Trac XmlRpcPlugin.
 * 
 * @author Steffen Pingel
 * @author Xiaoyang Guan
 */
public class TracXmlRpcClient extends AbstractTracClient implements ITracWikiClient {

	private static final Pattern ERROR_PATTERN_RPC_METHOD_NOT_FOUND = Pattern.compile("RPC method \".*\" not found"); //$NON-NLS-1$

	private static final Pattern ERROR_PATTERN_MID_AIR_COLLISION = Pattern.compile("Sorry, can not save your changes.*This ticket has been modified by someone else since you started"); //$NON-NLS-1$

	private static final String ERROR_XML_RPC_PRIVILEGES_REQUIRED = "XML_RPC privileges are required to perform this operation"; //$NON-NLS-1$

	private class XmlRpcRequest {

		private final String method;

		private final Object[] parameters;

		public XmlRpcRequest(String method, Object[] parameters) {
			this.method = method;
			this.parameters = parameters;
		}

		public Object execute(IProgressMonitor monitor) throws TracException {
			try {
				// first attempt
				return executeCallInternal(monitor);
			} catch (TracPermissionDeniedException e) {
				if (accountMangerAuthenticationFailed) {
					// do not try again if this has failed in the past since it
					// is more likely that XML_RPC permissions have not been set
					throw e;
				}

				AuthenticationCredentials credentials = location.getCredentials(AuthenticationType.REPOSITORY);
				if (!credentialsValid(credentials)) {
					throw e;
				}

				// try form-based authentication via AccountManagerPlugin as a
				// fall-back
				HostConfiguration hostConfiguration = WebUtil.createHostConfiguration(httpClient, location, monitor);
				try {
					authenticateAccountManager(httpClient, hostConfiguration, credentials, monitor);
				} catch (TracLoginException loginException) {
					// caused by wrong user name or password
					throw loginException;
				} catch (IOException ignore) {
					accountMangerAuthenticationFailed = true;
					throw e;
				}

				try {
					validateAuthenticationState(httpClient);
				} catch (TracLoginException ignore) {
					// most likely form based authentication is not supported by
					// repository
					accountMangerAuthenticationFailed = true;
					throw e;
				}

				// the authentication information is available through the shared state in httpClient
			}

			// second attempt
			return executeCallInternal(monitor);
		}

		private Object executeCallInternal(IProgressMonitor monitor) throws TracException {
			try {
				if (isTracd && digestScheme != null) {
					probeAuthenticationScheme(monitor);
				}
				if (DEBUG_XMLRPC) {
					System.err.println("Calling " + location.getUrl() + ": " + method + " " + CoreUtil.toString(parameters)); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
				}
				TracXmlRpcClientRequest request = new TracXmlRpcClientRequest(xmlrpc.getClientConfig(), method,
						parameters, monitor);
				return xmlrpc.execute(request);
			} catch (TracHttpException e) {
				handleAuthenticationException(e.code, e.getAuthScheme());
				// if not handled, throw generic exception
				throw new TracException(e);
			} catch (XmlRpcException e) {
				// XXX work-around for http://trac-hacks.org/ticket/5848 
				if (ERROR_XML_RPC_PRIVILEGES_REQUIRED.equals(e.getMessage()) || e.code == XML_FAULT_PERMISSION_DENIED) {
					handleAuthenticationException(HttpStatus.SC_FORBIDDEN, null);
					// should never happen as call above should always throw an exception
					throw new TracRemoteException(e);
				} else if (isNoSuchMethodException(e)) {
					throw new TracNoSuchMethodException(e);
				} else if (isMidAirCollision(e)) {
					throw new TracMidAirCollisionException(e);
				} else {
					throw new TracRemoteException(e);
				}
			} catch (OperationCanceledException e) {
				throw e;
			} catch (Exception e) {
				throw new TracException(e);
			}
		}

		private boolean isMidAirCollision(XmlRpcException e) {
			if (e.code == XML_FAULT_GENERAL_ERROR && e.getMessage() != null
					&& ERROR_PATTERN_MID_AIR_COLLISION.matcher(e.getMessage()).find()) {
				return true;
			}
			return false;
		}

		private boolean isNoSuchMethodException(XmlRpcException e) {
			// the fault code is used for various errors, therefore detection is based on the message
			// message format by XML-RPC Plugin version:
			//  1.0.1: XML-RPC method "ticket.ge1t" not found
			//  1.0.6: RPC method "ticket.ge1t" not found
			//  1.10:  RPC method "ticket.ge1t" not found' while executing 'ticket.ge1t()
			if (e.code == XML_FAULT_GENERAL_ERROR && e.getMessage() != null
					&& ERROR_PATTERN_RPC_METHOD_NOT_FOUND.matcher(e.getMessage()).find()) {
				return true;
			}
			return false;
		}

		protected boolean handleAuthenticationException(int code, AuthScheme authScheme) throws TracException {
			if (code == HttpStatus.SC_UNAUTHORIZED) {
				if (DEBUG_AUTH) {
					System.err.println(location.getUrl() + ": Unauthorized (" + code + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
				}
				digestScheme = null;
				TracLoginException exception = new TracLoginException();
				exception.setNtlmAuthRequested(authScheme instanceof NTLMScheme);
				throw exception;
			} else if (code == HttpStatus.SC_FORBIDDEN) {
				if (DEBUG_AUTH) {
					System.err.println(location.getUrl() + ": Forbidden (" + code + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
				}
				digestScheme = null;
				throw new TracPermissionDeniedException();
			} else if (code == HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED) {
				if (DEBUG_AUTH) {
					System.err.println(location.getUrl() + ": Proxy authentication required (" + code + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
				}
				throw new TracProxyAuthenticationException();
			} else if (code == SC_CERT_AUTH_FAILED) {
				if (DEBUG_AUTH) {
					System.err.println(location.getUrl() + ": Certificate authentication failed (" + code + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
				}
				throw new TracSslCertificateException();
			}
			return false;
		}
	}

	private static final boolean DEBUG_XMLRPC = Boolean.valueOf(Platform.getDebugOption("org.eclipse.mylyn.trac.core/debug/xmlrpc")); //$NON-NLS-1$

	public static final String XMLRPC_URL = "/xmlrpc"; //$NON-NLS-1$

	public static final String REQUIRED_REVISION = "1950"; //$NON-NLS-1$

	public static final int REQUIRED_EPOCH = 0;

	public static final int REQUIRED_MAJOR = 0;

	public static final int REQUIRED_MINOR = 1;

	// XML-RPC Plugin <1.10
	private static final int XML_FAULT_GENERAL_ERROR = 1;

	// since XML-RPC Plugin 1.10
	@SuppressWarnings("unused")
	private static final int XML_FAULT_RESOURCE_NOT_FOUND = 404;

	// since XML-RPC Plugin 1.10
	private static final int XML_FAULT_PERMISSION_DENIED = 403;

	private static final int LATEST_VERSION = -1;

	public static final int REQUIRED_WIKI_RPC_VERSION = 2;

	private XmlRpcClient xmlrpc;

	private TracHttpClientTransportFactory factory;

	private boolean accountMangerAuthenticationFailed;

	private XmlRpcClientConfigImpl config;

	private final HttpClient httpClient;

	private boolean probed;

	private volatile DigestScheme digestScheme;

	private final AuthScope authScope;

	private boolean isTracd;

	private TracRepositoryInfo info = new TracRepositoryInfo();

	public TracXmlRpcClient(AbstractWebLocation location, Version version) {
		super(location, version);
		this.httpClient = createHttpClient();
		this.authScope = new AuthScope(WebUtil.getHost(repositoryUrl), WebUtil.getPort(repositoryUrl), null,
				AuthScope.ANY_SCHEME);
	}

	public synchronized XmlRpcClient getClient() throws TracException {
		if (xmlrpc == null) {
			config = new XmlRpcClientConfigImpl();
			config.setEncoding(ITracClient.CHARSET);
			config.setTimeZone(TimeZone.getTimeZone(ITracClient.TIME_ZONE));
			config.setContentLengthOptional(false);
			config.setConnectionTimeout(WebUtil.getConnectionTimeout());
			config.setReplyTimeout(WebUtil.getSocketTimeout());

			xmlrpc = new XmlRpcClient();
			xmlrpc.setConfig(config);
			// bug 307200: force factory that supports proper UTF-8 encoding
			xmlrpc.setXmlWriterFactory(new CharSetXmlWriterFactory());

			factory = new TracHttpClientTransportFactory(xmlrpc, httpClient);
			factory.setLocation(location);
			factory.setInterceptor(new HttpMethodInterceptor() {
				public void processRequest(HttpMethod method) {
					DigestScheme scheme = digestScheme;
					if (scheme != null) {
						if (DEBUG_AUTH) {
							System.err.println(location.getUrl() + ": Digest scheme is present"); //$NON-NLS-1$ 
						}
						Credentials creds = httpClient.getState().getCredentials(authScope);
						if (creds != null) {
							if (DEBUG_AUTH) {
								System.err.println(location.getUrl() + ": Setting digest scheme for request"); //$NON-NLS-1$ 
							}
							method.getHostAuthState().setAuthScheme(digestScheme);
							method.getHostAuthState().setAuthRequested(true);
						}
					}
				}

				public void processResponse(HttpMethod method) {
					AuthScheme authScheme = method.getHostAuthState().getAuthScheme();
					if (authScheme instanceof DigestScheme) {
						digestScheme = (DigestScheme) authScheme;
						if (DEBUG_AUTH) {
							System.err.println(location.getUrl() + ": Received digest scheme"); //$NON-NLS-1$ 
						}
					}
				}
			});
			xmlrpc.setTransportFactory(factory);

			// update configuration with latest values
			AuthenticationCredentials credentials = location.getCredentials(AuthenticationType.REPOSITORY);
			config.setServerURL(getXmlRpcUrl(credentials));
			if (credentialsValid(credentials)) {
				Credentials httpCredentials = WebUtil.getHttpClientCredentials(credentials,
						WebUtil.getHost(location.getUrl()));
				httpClient.getState().setCredentials(authScope, httpCredentials);
//				if (CoreUtil.TEST_MODE) {
//					System.err.println(" Setting credentials: " + httpCredentials); //$NON-NLS-1$
//				}
				httpClient.getState().setCredentials(authScope, httpCredentials);
			} else {
				httpClient.getState().clearCredentials();
			}
		}

		return xmlrpc;
	}

	private URL getXmlRpcUrl(AuthenticationCredentials credentials) throws TracException {
		try {
			String location = repositoryUrl.toString();
			if (credentialsValid(credentials)) {
				location += LOGIN_URL;
			}
			location += XMLRPC_URL;

			return new URL(location);
		} catch (Exception e) {
			throw new TracException(e);
		}
	}

	private void probeAuthenticationScheme(IProgressMonitor monitor) throws TracException {
		AuthenticationCredentials credentials = location.getCredentials(AuthenticationType.REPOSITORY);
		if (!credentialsValid(credentials)) {
			return;
		}

		if (DEBUG_AUTH) {
			System.err.println(location.getUrl() + ": Probing authentication"); //$NON-NLS-1$ 
		}
		HostConfiguration hostConfiguration = WebUtil.createHostConfiguration(httpClient, location, monitor);
		HeadMethod method = new HeadMethod(getXmlRpcUrl(credentials).toString());
		try {
			// execute without any credentials set
			int result = WebUtil.execute(httpClient, hostConfiguration, method, new HttpState(), monitor);
			if (DEBUG_AUTH) {
				System.err.println(location.getUrl() + ": Received authentication response (" + result + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
			}
			if (result == HttpStatus.SC_UNAUTHORIZED || result == HttpStatus.SC_FORBIDDEN) {
				AuthScheme authScheme = method.getHostAuthState().getAuthScheme();
				if (authScheme instanceof DigestScheme) {
					this.digestScheme = (DigestScheme) authScheme;
					if (DEBUG_AUTH) {
						System.err.println(location.getUrl() + ": Received digest scheme"); //$NON-NLS-1$ 
					}
				} else if (authScheme instanceof BasicScheme) {
					httpClient.getParams().setAuthenticationPreemptive(true);
					if (DEBUG_AUTH) {
						System.err.println(location.getUrl() + ": Received basic scheme"); //$NON-NLS-1$ 
					}
				} else if (authScheme != null) {
					if (DEBUG_AUTH) {
						System.err.println(location.getUrl() + ": Received scheme (" + authScheme.getClass() + ")"); //$NON-NLS-1$ //$NON-NLS-2$ 
					}
				} else {
					if (DEBUG_AUTH) {
						System.err.println(location.getUrl() + ": No authentication scheme received"); //$NON-NLS-1$ 
					}
				}

				Header header = method.getResponseHeader("Server"); //$NON-NLS-1$
				isTracd = (header != null && header.getValue().startsWith("tracd")); //$NON-NLS-1$
				if (DEBUG_AUTH && isTracd) {
					System.err.println(location.getUrl() + ": Tracd detected"); //$NON-NLS-1$ 
				}

//					Header header = method.getResponseHeader("WWW-Authenticate");
//					if (header != null) {
//						if (header.getValue().startsWith("Basic")) {
//							httpClient.getParams().setAuthenticationPreemptive(true);
//						} else if (header.getValue().startsWith("Digest")) {
//							DigestScheme scheme = new DigestScheme();
//							try {
//								scheme.processChallenge(header.getValue());
//								this.digestScheme = scheme;
//							} catch (MalformedChallengeException e) {
//								// ignore
//							}
//						}
//					}
			}
		} catch (IOException e) {
			// ignore
		} finally {
			WebUtil.releaseConnection(method, monitor);
		}
	}

	private Object call(IProgressMonitor monitor, String method, Object... parameters) throws TracException {
		monitor = Policy.monitorFor(monitor);
		TracException lastException = null;
		for (int attempt = 0; attempt < 3; attempt++) {
			if (!probed) {
				try {
					probeAuthenticationScheme(monitor);
				} finally {
					probed = true;
				}
			}

			getClient();

			try {
				XmlRpcRequest request = new XmlRpcRequest(method, parameters);
				return request.execute(monitor);
			} catch (TracLoginException e) {
				try {
					location.requestCredentials(AuthenticationType.REPOSITORY, null, monitor);
				} catch (UnsupportedRequestException ignored) {
					throw e;
				}
				lastException = e;
			} catch (TracPermissionDeniedException e) {
				try {
					location.requestCredentials(AuthenticationType.REPOSITORY, null, monitor);
				} catch (UnsupportedRequestException ignored) {
					throw e;
				}
				lastException = e;
			} catch (TracProxyAuthenticationException e) {
				try {
					location.requestCredentials(AuthenticationType.PROXY, null, monitor);
				} catch (UnsupportedRequestException ignored) {
					throw e;
				}
				lastException = e;
			} catch (TracSslCertificateException e) {
				try {
					location.requestCredentials(AuthenticationType.CERTIFICATE, null, monitor);
				} catch (UnsupportedRequestException ignored) {
					throw e;
				}
				lastException = e;
			}
		}
		if (lastException != null) {
			throw lastException;
		} else {
			// this path should never be reached
			throw new IllegalStateException();
		}
	}

	private Object[] multicall(IProgressMonitor monitor, Map<String, Object>... calls) throws TracException {
		Object[] result = (Object[]) call(monitor, "system.multicall", new Object[] { calls }); //$NON-NLS-1$
		for (Object item : result) {
			try {
				checkForException(item);
			} catch (XmlRpcException e) {
				throw new TracRemoteException(e);
			} catch (Exception e) {
				throw new TracException(e);
			}
		}
		return result;
	}

	private void checkForException(Object result) throws NumberFormatException, XmlRpcException {
		if (result instanceof Map<?, ?>) {
			Map<?, ?> exceptionData = (Map<?, ?>) result;
			if (exceptionData.containsKey("faultCode") && exceptionData.containsKey("faultString")) { //$NON-NLS-1$ //$NON-NLS-2$ 
				throw new XmlRpcException(Integer.parseInt(exceptionData.get("faultCode").toString()), //$NON-NLS-1$
						(String) exceptionData.get("faultString")); //$NON-NLS-1$
			} else if (exceptionData.containsKey("title")) { //$NON-NLS-1$
				String message = (String) exceptionData.get("title"); //$NON-NLS-1$
				String detail = (String) exceptionData.get("_message"); //$NON-NLS-1$
				if (detail != null) {
					message += ": " + detail; //$NON-NLS-1$
				}
				throw new XmlRpcException(XML_FAULT_GENERAL_ERROR, message);
			}
		}
	}

	private Map<String, Object> createMultiCall(String methodName, Object... parameters) throws TracException {
		Map<String, Object> table = new HashMap<String, Object>();
		table.put("methodName", methodName); //$NON-NLS-1$
		table.put("params", parameters); //$NON-NLS-1$
		return table;
	}

	private Object getMultiCallResult(Object item) {
		return ((Object[]) item)[0];
	}

	public TracRepositoryInfo validate(IProgressMonitor monitor) throws TracException {
		Integer epochAPIVersion;
		Integer majorAPIVersion;
		Integer minorAPIVersion;
		try {
			Object[] result = (Object[]) call(monitor, "system.getAPIVersion"); //$NON-NLS-1$
			if (result.length >= 3) {
				epochAPIVersion = (Integer) result[0];
				majorAPIVersion = (Integer) result[1];
				minorAPIVersion = (Integer) result[2];
			} else if (result.length >= 2) {
				epochAPIVersion = 0;
				majorAPIVersion = (Integer) result[0];
				minorAPIVersion = (Integer) result[1];
			} else {
				throw new TracException(NLS.bind(Messages.TracXmlRpcClient_API_version_unsupported_Error,
						REQUIRED_REVISION));
			}
		} catch (TracNoSuchMethodException e) {
			throw new TracException(NLS.bind(Messages.TracXmlRpcClient_Required_API_calls_missing_Error,
					REQUIRED_REVISION));
		}

		info = new TracRepositoryInfo(epochAPIVersion, majorAPIVersion, minorAPIVersion);
		if (!info.isApiVersionOrHigher(REQUIRED_EPOCH, REQUIRED_MAJOR, REQUIRED_MINOR)) {
			throw new TracException(NLS.bind(Messages.TracXmlRpcClient_API_version_X_unsupported_Error,
					info.toString(), REQUIRED_REVISION));
		}
		return info;
	}

	private void updateAPIVersion(IProgressMonitor monitor) throws TracException {
		if (info.isStale()) {
			validate(monitor);
		}
	}

	private boolean isApiVersionOrHigher(int epoch, int major, int minor, IProgressMonitor monitor)
			throws TracException {
		updateAPIVersion(monitor);
		return info.isApiVersionOrHigher(epoch, major, minor);
	}

	public List<TracComment> getComments(int id, IProgressMonitor monitor) throws TracException {
		Object[] result = (Object[]) call(monitor, "ticket.changeLog", id, 0); //$NON-NLS-1$
		List<TracComment> comments = new ArrayList<TracComment>(result.length);
		for (Object item : result) {
			comments.add(parseChangeLogEntry((Object[]) item));
		}
		return comments;
	}

	public TracTicket getTicket(int id, IProgressMonitor monitor) throws TracException {
		Object[] result = (Object[]) call(monitor, "ticket.get", id); //$NON-NLS-1$
		TracTicket ticket = parseTicket(result);

		result = (Object[]) call(monitor, "ticket.changeLog", id, 0); //$NON-NLS-1$
		for (Object item : result) {
			ticket.addComment(parseChangeLogEntry((Object[]) item));
		}

		result = (Object[]) call(monitor, "ticket.listAttachments", id); //$NON-NLS-1$
		for (Object item : result) {
			ticket.addAttachment(parseAttachment((Object[]) item));
		}

		TracAction[] actions = getActions(id, monitor);
		ticket.setActions(actions);

		updateAttributes(new NullProgressMonitor(), false);
		TracTicketResolution[] resolutions = getTicketResolutions();
		if (resolutions != null) {
			String[] resolutionStrings = new String[resolutions.length];
			for (int i = 0; i < resolutions.length; i++) {
				resolutionStrings[i] = resolutions[i].getName();
			}
			ticket.setResolutions(resolutionStrings);
		} else {
			ticket.setResolutions(getDefaultTicketResolutions());
		}

		return ticket;
	}

	private TracAttachment parseAttachment(Object[] entry) {
		TracAttachment attachment = new TracAttachment((String) entry[0]);
		attachment.setDescription((String) entry[1]);
		attachment.setSize((Integer) entry[2]);
		attachment.setCreated(parseDate(entry[3]));
		attachment.setAuthor((String) entry[4]);
		return attachment;
	}

	private TracComment parseChangeLogEntry(Object[] entry) {
		TracComment comment = new TracComment();
		comment.setCreated(parseDate(entry[0]));
		comment.setAuthor((String) entry[1]);
		comment.setField((String) entry[2]);
		comment.setOldValue((String) entry[3]);
		comment.setNewValue((String) entry[4]);
		return comment;
	}

	/* public for testing */
	@SuppressWarnings("unchecked")
	public List<TracTicket> getTickets(int[] ids, IProgressMonitor monitor) throws TracException {
		Map<String, Object>[] calls = new Map[ids.length];
		for (int i = 0; i < calls.length; i++) {
			calls[i] = createMultiCall("ticket.get", ids[i]); //$NON-NLS-1$
		}

		Object[] result = multicall(monitor, calls);
		assert result.length == ids.length;

		List<TracTicket> tickets = new ArrayList<TracTicket>(result.length);
		for (Object item : result) {
			Object[] ticketResult = (Object[]) getMultiCallResult(item);
			tickets.add(parseTicket(ticketResult));
		}

		return tickets;
	}

	public void searchForTicketIds(TracSearch query, List<Integer> tickets, IProgressMonitor monitor)
			throws TracException {
		// an empty query string is not valid, therefore prepend order
		Object[] result = (Object[]) call(monitor,
				"ticket.query", "order=id" + query.toQuery(supportsMaxSearchResults(monitor))); //$NON-NLS-1$ //$NON-NLS-2$
		for (Object item : result) {
			tickets.add((Integer) item);
		}
	}

	@SuppressWarnings("unchecked")
	public void search(TracSearch query, List<TracTicket> tickets, IProgressMonitor monitor) throws TracException {
		// an empty query string is not valid, therefore prepend order
		Object[] result = (Object[]) call(monitor,
				"ticket.query", "order=id" + query.toQuery(supportsMaxSearchResults(monitor))); //$NON-NLS-1$ //$NON-NLS-2$

		Map<String, Object>[] calls = new Map[result.length];
		for (int i = 0; i < calls.length; i++) {
			calls[i] = createMultiCall("ticket.get", result[i]); //$NON-NLS-1$
		}
		result = multicall(monitor, calls);

		for (Object item : result) {
			Object[] ticketResult = (Object[]) getMultiCallResult(item);
			tickets.add(parseTicket(ticketResult));
		}
	}

	private boolean supportsWorkFlow(IProgressMonitor monitor) throws TracException {
		return isApiVersionOrHigher(1, 0, 1, monitor);
	}

	private boolean supportsMaxSearchResults(IProgressMonitor monitor) throws TracException {
		return isApiVersionOrHigher(1, 0, 0, monitor);
	}

	private TracTicket parseTicket(Object[] ticketResult) throws InvalidTicketException {
		TracTicket ticket = new TracTicket((Integer) ticketResult[0]);
		ticket.setCreated(parseDate(ticketResult[1]));
		ticket.setLastChanged(parseDate(ticketResult[2]));
		Map<?, ?> attributes = (Map<?, ?>) ticketResult[3];
		for (Object key : attributes.keySet()) {
			ticket.putValue(key.toString(), attributes.get(key).toString());
		}
		return ticket;
	}

	private Date parseDate(Object object) {
		if (object instanceof Date) {
			return (Date) object;
		} else if (object instanceof Integer) {
			return TracUtil.parseDate((Integer) object);
		}
		throw new ClassCastException("Unexpected object type for date: " + object.getClass()); //$NON-NLS-1$
	}

	@Override
	public synchronized void updateAttributes(IProgressMonitor monitor) throws TracException {
		monitor.beginTask("Updating attributes", 9); //$NON-NLS-1$

		Object[] result = getAttributes("ticket.component", monitor); //$NON-NLS-1$
		data.components = new ArrayList<TracComponent>(result.length);
		for (Object item : result) {
			data.components.add(parseComponent((Map<?, ?>) getMultiCallResult(item)));
		}
		advance(monitor, 1);

		result = getAttributes("ticket.milestone", monitor); //$NON-NLS-1$
		data.milestones = new ArrayList<TracMilestone>(result.length);
		for (Object item : result) {
			data.milestones.add(parseMilestone((Map<?, ?>) getMultiCallResult(item)));
		}
		advance(monitor, 1);

		List<TicketAttributeResult> attributes = getTicketAttributes("ticket.priority", monitor); //$NON-NLS-1$
		data.priorities = new ArrayList<TracPriority>(result.length);
		for (TicketAttributeResult attribute : attributes) {
			data.priorities.add(new TracPriority(attribute.name, attribute.value));
		}
		Collections.sort(data.priorities);
		advance(monitor, 1);

		attributes = getTicketAttributes("ticket.resolution", monitor); //$NON-NLS-1$
		data.ticketResolutions = new ArrayList<TracTicketResolution>(result.length);
		for (TicketAttributeResult attribute : attributes) {
			data.ticketResolutions.add(new TracTicketResolution(attribute.name, attribute.value));
		}
		Collections.sort(data.ticketResolutions);
		advance(monitor, 1);

		attributes = getTicketAttributes("ticket.severity", monitor); //$NON-NLS-1$
		data.severities = new ArrayList<TracSeverity>(result.length);
		for (TicketAttributeResult attribute : attributes) {
			data.severities.add(new TracSeverity(attribute.name, attribute.value));
		}
		Collections.sort(data.severities);
		advance(monitor, 1);

		boolean assignValues = isApiVersionOrHigher(1, 0, 0, monitor);
		attributes = getTicketAttributes("ticket.status", assignValues, monitor); //$NON-NLS-1$
		data.ticketStatus = new ArrayList<TracTicketStatus>(result.length);
		for (TicketAttributeResult attribute : attributes) {
			data.ticketStatus.add(new TracTicketStatus(attribute.name, attribute.value));
		}
		Collections.sort(data.ticketStatus);
		advance(monitor, 1);

		attributes = getTicketAttributes("ticket.type", monitor); //$NON-NLS-1$
		data.ticketTypes = new ArrayList<TracTicketType>(result.length);
		for (TicketAttributeResult attribute : attributes) {
			data.ticketTypes.add(new TracTicketType(attribute.name, attribute.value));
		}
		Collections.sort(data.ticketTypes);
		advance(monitor, 1);

		result = getAttributes("ticket.version", monitor); //$NON-NLS-1$
		data.versions = new ArrayList<TracVersion>(result.length);
		for (Object item : result) {
			data.versions.add(parseVersion((Map<?, ?>) getMultiCallResult(item)));
		}
		advance(monitor, 1);

		result = (Object[]) call(monitor, "ticket.getTicketFields"); //$NON-NLS-1$
		data.ticketFields = new ArrayList<TracTicketField>(result.length);
		data.ticketFieldByName = null;
		for (Object item : result) {
			data.ticketFields.add(parseTicketField((Map<?, ?>) item));
		}
		advance(monitor, 1);
	}

	private void advance(IProgressMonitor monitor, int worked) {
		monitor.worked(worked);
		if (monitor.isCanceled()) {
			throw new OperationCanceledException();
		}

	}

	private TracComponent parseComponent(Map<?, ?> result) {
		TracComponent component = new TracComponent((String) result.get("name")); //$NON-NLS-1$
		component.setOwner((String) result.get("owner")); //$NON-NLS-1$
		component.setDescription((String) result.get("description")); //$NON-NLS-1$
		return component;
	}

	private TracMilestone parseMilestone(Map<?, ?> result) {
		TracMilestone milestone = new TracMilestone((String) result.get("name")); //$NON-NLS-1$
		milestone.setCompleted(parseDate(result.get("completed"))); //$NON-NLS-1$
		milestone.setDue(parseDate(result.get("due"))); //$NON-NLS-1$
		milestone.setDescription((String) result.get("description")); //$NON-NLS-1$
		return milestone;
	}

	private TracVersion parseVersion(Map<?, ?> result) {
		TracVersion version = new TracVersion((String) result.get("name")); //$NON-NLS-1$
		version.setTime(parseDate(result.get("time"))); //$NON-NLS-1$
		version.setDescription((String) result.get("description")); //$NON-NLS-1$
		return version;
	}

	private TracTicketField parseTicketField(Map<?, ?> result) {
		TracTicketField field = new TracTicketField((String) result.get("name")); //$NON-NLS-1$
		field.setType(TracTicketField.Type.fromString((String) result.get("type"))); //$NON-NLS-1$
		field.setLabel((String) result.get("label")); //$NON-NLS-1$
		field.setDefaultValue((String) result.get("value")); //$NON-NLS-1$
		Object[] items = (Object[]) result.get("options"); //$NON-NLS-1$
		if (items != null) {
			String[] options = new String[items.length];
			for (int i = 0; i < items.length; i++) {
				options[i] = (String) items[i];
			}
			field.setOptions(options);
		}
		if (result.get("custom") != null) { //$NON-NLS-1$
			field.setCustom((Boolean) result.get("custom")); //$NON-NLS-1$
		}
		if (result.get("order") != null) { //$NON-NLS-1$
			field.setOrder((Integer) result.get("order")); //$NON-NLS-1$
		}
		if (result.get("optional") != null) { //$NON-NLS-1$
			field.setOptional((Boolean) result.get("optional")); //$NON-NLS-1$
		}
		if (result.get("width") != null) { //$NON-NLS-1$
			field.setWidth((Integer) result.get("width")); //$NON-NLS-1$
		}
		if (result.get("height") != null) { //$NON-NLS-1$
			field.setHeight((Integer) result.get("height")); //$NON-NLS-1$
		}
		return field;
	}

	@SuppressWarnings("unchecked")
	private Object[] getAttributes(String attributeType, IProgressMonitor monitor) throws TracException {
		Object[] ids = (Object[]) call(monitor, attributeType + ".getAll"); //$NON-NLS-1$
		Map<String, Object>[] calls = new Map[ids.length];
		for (int i = 0; i < calls.length; i++) {
			calls[i] = createMultiCall(attributeType + ".get", ids[i]); //$NON-NLS-1$
		}

		Object[] result = multicall(monitor, calls);
		assert result.length == ids.length;

		return result;
	}

	private List<TicketAttributeResult> getTicketAttributes(String attributeType, IProgressMonitor monitor)
			throws TracException {
		return getTicketAttributes(attributeType, false, monitor);
	}

	@SuppressWarnings("unchecked")
	private List<TicketAttributeResult> getTicketAttributes(String attributeType, boolean assignValues,
			IProgressMonitor monitor) throws TracException {
		// get list of attribute ids first
		Object[] ids = (Object[]) call(monitor, attributeType + ".getAll"); //$NON-NLS-1$
		// fetch all attributes in a single call
		Map<String, Object>[] calls = new Map[ids.length];
		for (int i = 0; i < calls.length; i++) {
			calls[i] = createMultiCall(attributeType + ".get", ids[i]); //$NON-NLS-1$
		}

		Object[] result = multicall(monitor, calls);
		assert result.length == ids.length;

		List<TicketAttributeResult> attributes = new ArrayList<TicketAttributeResult>(result.length);
		for (int i = 0; i < calls.length; i++) {
			try {
				TicketAttributeResult attribute = new TicketAttributeResult();
				attribute.name = (String) ids[i];
				Object value = getMultiCallResult(result[i]);
				if (assignValues) {
					attribute.value = i;
				} else {
					attribute.value = (value instanceof Integer) ? (Integer) value : Integer.parseInt((String) value);
				}
				attributes.add(attribute);
			} catch (ClassCastException e) {
				StatusHandler.log(new Status(IStatus.WARNING, TracCorePlugin.ID_PLUGIN,
						"Invalid response from Trac repository for attribute type: '" + attributeType + "'", e)); //$NON-NLS-1$ //$NON-NLS-2$
			} catch (NumberFormatException e) {
				StatusHandler.log(new Status(IStatus.WARNING, TracCorePlugin.ID_PLUGIN,
						"Invalid response from Trac repository for attribute type: '" + attributeType + "'", e)); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}

		return attributes;
	}

	public InputStream getAttachmentData(int ticketId, String filename, IProgressMonitor monitor) throws TracException {
		byte[] data = (byte[]) call(monitor, "ticket.getAttachment", ticketId, filename); //$NON-NLS-1$
		return new ByteArrayInputStream(data);
	}

	public void putAttachmentData(int ticketId, String filename, String description, InputStream in,
			IProgressMonitor monitor, boolean replace) throws TracException {
		byte[] data;
		try {
			data = readData(in, new NullProgressMonitor());
		} catch (IOException e) {
			throw new TracException(e);
		}
		call(monitor, "ticket.putAttachment", ticketId, filename, description, data, replace); //$NON-NLS-1$
	}

	private byte[] readData(InputStream in, IProgressMonitor monitor) throws IOException {
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		try {
			byte[] buffer = new byte[512];
			while (true) {
				int count = in.read(buffer);
				if (count == -1) {
					return out.toByteArray();
				}
				if (monitor.isCanceled()) {
					throw new OperationCanceledException();
				}
				out.write(buffer, 0, count);
				if (monitor.isCanceled()) {
					throw new OperationCanceledException();
				}
			}
		} finally {
			try {
				in.close();
			} catch (IOException e) {
				StatusHandler.log(new Status(IStatus.ERROR, TracCorePlugin.ID_PLUGIN,
						"Error closing attachment stream", e)); //$NON-NLS-1$
			}
		}
	}

	public void deleteAttachment(int ticketId, String filename, IProgressMonitor monitor) throws TracException {
		call(monitor, "ticket.deleteAttachment", ticketId, filename); //$NON-NLS-1$
	}

	private class TicketAttributeResult {

		String name;

		int value;

	}

	public int createTicket(TracTicket ticket, IProgressMonitor monitor) throws TracException {
		Map<String, String> attributes = ticket.getValues();
		String summary = attributes.remove(Key.SUMMARY.getKey());
		String description = attributes.remove(Key.DESCRIPTION.getKey());
		if (summary == null || description == null) {
			throw new InvalidTicketException();
		}
		if (supportsNotifications(monitor)) {
			return (Integer) call(monitor, "ticket.create", summary, description, attributes, true); //$NON-NLS-1$
		} else {
			return (Integer) call(monitor, "ticket.create", summary, description, attributes); //$NON-NLS-1$
		}
	}

	private boolean supportsNotifications(IProgressMonitor monitor) throws TracException {
		return isApiVersionOrHigher(0, 0, 2, monitor);
	}

	public void updateTicket(TracTicket ticket, String comment, IProgressMonitor monitor) throws TracException {
		updateAPIVersion(monitor);

		Map<String, String> attributes = ticket.getValues();
		if (!supportsWorkFlow(monitor)) {
			// submitted as part of status and resolution updates for Trac < 0.11
			attributes.remove("action"); //$NON-NLS-1$
			// avoid confusing older XML-RPC plugin versions
			attributes.remove(Key.TOKEN.getKey());
		}

		if (supportsNotifications(monitor)) {
			call(monitor, "ticket.update", ticket.getId(), comment, attributes, true); //$NON-NLS-1$
		} else {
			call(monitor, "ticket.update", ticket.getId(), comment, attributes); //$NON-NLS-1$
		}
	}

	public Set<Integer> getChangedTickets(Date since, IProgressMonitor monitor) throws TracException {
		Object[] ids;
		ids = (Object[]) call(monitor, "ticket.getRecentChanges", since); //$NON-NLS-1$
		Set<Integer> result = new HashSet<Integer>();
		for (Object id : ids) {
			result.add((Integer) id);
		}
		return result;
	}

	public TracAction[] getActions(int id, IProgressMonitor monitor) throws TracException {
		if (supportsWorkFlow(monitor)) {
			Object[] actions = (Object[]) call(monitor, "ticket.getActions", id); //$NON-NLS-1$
			TracAction[] result = new TracAction[actions.length];
			for (int i = 0; i < result.length; i++) {
				Object[] entry = (Object[]) actions[i];
				TracAction action = new TracAction((String) entry[0]);
				action.setLabel((String) entry[1]);
				action.setHint((String) entry[2]);
				Object[] inputs = (Object[]) entry[3];
				// each action can be associated with fields
				for (Object inputArray : inputs) {
					Object[] inputEntry = (Object[]) inputArray;
					TracTicketField field = new TracTicketField((String) inputEntry[0]);
					field.setDefaultValue((String) inputEntry[1]);
					Object[] optionEntry = (Object[]) inputEntry[2];
					if (optionEntry.length == 0) {
						field.setType(Type.TEXT);
					} else {
						field.setType(Type.SELECT);
						String[] options = new String[optionEntry.length];
						for (int j = 0; j < options.length; j++) {
							options[j] = (String) optionEntry[j];
						}
						field.setOptions(options);
					}
					action.addField(field);
				}
				result[i] = action;
			}
			return result;
		} else {
			Object[] actions = (Object[]) call(monitor, "ticket.getAvailableActions", id); //$NON-NLS-1$
			TracAction[] result = new TracAction[actions.length];
			for (int i = 0; i < result.length; i++) {
				result[i] = new TracAction((String) actions[i]);
			}
			return result;
		}
	}

	public Date getTicketLastChanged(Integer id, IProgressMonitor monitor) throws TracException {
		Object[] result = (Object[]) call(monitor, "ticket.get", id); //$NON-NLS-1$
		return parseDate(result[2]);
	}

	public void validateWikiRpcApi(IProgressMonitor monitor) throws TracException {
		if (((Integer) call(monitor, "wiki.getRPCVersionSupported")) < 2) { //$NON-NLS-1$
			validate(monitor);
		}
	}

	public String wikiToHtml(String sourceText, IProgressMonitor monitor) throws TracException {
		return (String) call(monitor, "wiki.wikiToHtml", sourceText); //$NON-NLS-1$
	}

	public String[] getAllWikiPageNames(IProgressMonitor monitor) throws TracException {
		Object[] result = (Object[]) call(monitor, "wiki.getAllPages"); //$NON-NLS-1$
		String[] wikiPageNames = new String[result.length];
		for (int i = 0; i < wikiPageNames.length; i++) {
			wikiPageNames[i] = (String) result[i];
		}
		return wikiPageNames;
	}

	public TracWikiPageInfo getWikiPageInfo(String pageName, IProgressMonitor monitor) throws TracException {
		return getWikiPageInfo(pageName, LATEST_VERSION, null);
	}

	public TracWikiPageInfo getWikiPageInfo(String pageName, int version, IProgressMonitor monitor)
			throws TracException {
		// Note: if an unexpected null value is passed to XmlRpcPlugin, XmlRpcClient will throw a TracRemoteException. 
		//       So, this null-parameter checking may be omitted if resorting to XmlRpcClient is more appropriate.
		if (pageName == null) {
			throw new IllegalArgumentException("Wiki page name cannot be null"); //$NON-NLS-1$
		}

		Object result = (version == LATEST_VERSION) ? call(monitor, "wiki.getPageInfo", pageName) // //$NON-NLS-1$
				: call(monitor, "wiki.getPageInfoVersion", pageName, version); //$NON-NLS-1$
		return parseWikiPageInfo(result);
	}

	@SuppressWarnings("unchecked")
	public TracWikiPageInfo[] getWikiPageInfoAllVersions(String pageName, IProgressMonitor monitor)
			throws TracException {
		TracWikiPageInfo latestVersion = getWikiPageInfo(pageName, null);
		Map<String, Object>[] calls = new Map[latestVersion.getVersion() - 1];
		for (int i = 0; i < calls.length; i++) {
			calls[i] = createMultiCall("wiki.getPageInfoVersion", pageName, i + 1); //$NON-NLS-1$
		}

		Object[] result = multicall(monitor, calls);

		TracWikiPageInfo[] versions = new TracWikiPageInfo[result.length + 1];
		for (int i = 0; i < result.length; i++) {
			Object pageInfoResult = getMultiCallResult(result[i]);
			versions[i] = parseWikiPageInfo(pageInfoResult);
		}
		versions[result.length] = latestVersion;

		return versions;
	}

	private TracWikiPageInfo parseWikiPageInfo(Object pageInfoResult) throws InvalidWikiPageException {
		// Note: Trac XML-RPC Plugin returns 0 (as Integer) if pageName or version doesn't exist, 
		//       and XmlRpcClient doesn't throw an Exception in this case
		if (pageInfoResult instanceof Map<?, ?>) {
			TracWikiPageInfo pageInfo = new TracWikiPageInfo();
			Map<?, ?> infoMap = (Map<?, ?>) pageInfoResult;
			pageInfo.setPageName((String) infoMap.get("name")); //$NON-NLS-1$
			pageInfo.setAuthor((String) infoMap.get("author")); //$NON-NLS-1$
			pageInfo.setLastModified(parseDate(infoMap.get("lastModified"))); //$NON-NLS-1$
			pageInfo.setVersion((Integer) infoMap.get("version")); //$NON-NLS-1$
			return pageInfo;
		} else {
			throw new InvalidWikiPageException("Wiki page name or version does not exist"); //$NON-NLS-1$
		}
	}

	public String getWikiPageContent(String pageName, IProgressMonitor monitor) throws TracException {
		return getWikiPageContent(pageName, LATEST_VERSION, null);
	}

	public String getWikiPageContent(String pageName, int version, IProgressMonitor monitor) throws TracException {
		// Note: if an unexpected null value is passed to XmlRpcPlugin, XmlRpcClient will throw a TracRemoteException. 
		//       So, this null-parameter checking may be omitted if resorting to XmlRpcClient is more appropriate.
		if (pageName == null) {
			throw new IllegalArgumentException("Wiki page name cannot be null"); //$NON-NLS-1$
		}
		if (version == LATEST_VERSION) {
			// XmlRpcClient throws a TracRemoteException if pageName or version doesn't exist
			return (String) call(monitor, "wiki.getPage", pageName); //$NON-NLS-1$
		} else {
			return (String) call(monitor, "wiki.getPageVersion", pageName, version); //$NON-NLS-1$
		}
	}

	public String getWikiPageHtml(String pageName, IProgressMonitor monitor) throws TracException {
		return getWikiPageHtml(pageName, LATEST_VERSION, null);
	}

	public String getWikiPageHtml(String pageName, int version, IProgressMonitor monitor) throws TracException {
		if (pageName == null) {
			throw new IllegalArgumentException("Wiki page name cannot be null"); //$NON-NLS-1$
		}

		if (version == LATEST_VERSION) {
			// XmlRpcClient throws a TracRemoteException if pageName or version doesn't exist
			return (String) call(monitor, "wiki.getPageHTML", pageName); //$NON-NLS-1$
		} else {
			return (String) call(monitor, "wiki.getPageHTMLVersion", pageName, version); //$NON-NLS-1$
		}
	}

	public TracWikiPageInfo[] getRecentWikiChanges(Date since, IProgressMonitor monitor) throws TracException {
		if (since == null) {
			throw new IllegalArgumentException("Date parameter cannot be null"); //$NON-NLS-1$
		}

		Object[] result = (Object[]) call(monitor, "wiki.getRecentChanges", since); //$NON-NLS-1$
		TracWikiPageInfo[] changes = new TracWikiPageInfo[result.length];
		for (int i = 0; i < result.length; i++) {
			changes[i] = parseWikiPageInfo(result[i]);
		}
		return changes;
	}

	public TracWikiPage getWikiPage(String pageName, IProgressMonitor monitor) throws TracException {
		return getWikiPage(pageName, LATEST_VERSION, null);
	}

	public TracWikiPage getWikiPage(String pageName, int version, IProgressMonitor monitor) throws TracException {
		TracWikiPage page = new TracWikiPage();
		page.setPageInfo(getWikiPageInfo(pageName, version, null));
		page.setContent(getWikiPageContent(pageName, version, null));
		page.setPageHTML(getWikiPageHtml(pageName, version, null));
		return page;
	}

	public boolean putWikipage(String pageName, String content, Map<String, Object> attributes, IProgressMonitor monitor)
			throws TracException {
		Boolean result = (Boolean) call(monitor, "wiki.putPage", pageName, content, attributes); //$NON-NLS-1$
		return result.booleanValue();
	}

	public String[] listWikiPageAttachments(String pageName, IProgressMonitor monitor) throws TracException {
		Object[] result = (Object[]) call(monitor, "wiki.listAttachments", pageName); //$NON-NLS-1$
		String[] attachments = new String[result.length];
		for (int i = 0; i < attachments.length; i++) {
			attachments[i] = (String) result[i];
		}
		return attachments;
	}

	public InputStream getWikiPageAttachmentData(String pageName, String fileName, IProgressMonitor monitor)
			throws TracException {
		String attachmentName = pageName + "/" + fileName; //$NON-NLS-1$
		byte[] data = (byte[]) call(monitor, "wiki.getAttachment", attachmentName); //$NON-NLS-1$
		return new ByteArrayInputStream(data);
	}

	/**
	 * Attach a file to a Wiki page on the repository.
	 * <p>
	 * This implementation uses the wiki.putAttachmentEx() call, which provides a richer functionality specific to Trac.
	 * 
	 * @param pageName
	 *            the name of the Wiki page
	 * @param fileName
	 *            the name of the file to be attached
	 * @param description
	 *            the description of the attachment
	 * @param in
	 *            An InputStream of the content of the attachment
	 * @param replace
	 *            whether to overwrite an existing attachment with the same filename
	 * @return The (possibly transformed) filename of the attachment. If <code>replace</code> is <code>true</code>, the
	 *         returned name is always the same as the argument <code>fileName</code>; if <code>replace</code> is
	 *         <code>false</code> and an attachment with name <code>fileName</code> already exists, a number is appended
	 *         to the file name (before suffix) and the generated filename of the attachment is returned.
	 * @throws TracException
	 */
	public String putWikiPageAttachmentData(String pageName, String fileName, String description, InputStream in,
			boolean replace, IProgressMonitor monitor) throws TracException {
		byte[] data;
		try {
			data = readData(in, new NullProgressMonitor());
		} catch (IOException e) {
			throw new TracException(e);
		}
		return (String) call(monitor, "wiki.putAttachmentEx", pageName, fileName, description, data, replace); //$NON-NLS-1$
	}

	public void deleteTicket(int ticketId, IProgressMonitor monitor) throws TracException {
		call(monitor, "ticket.delete", ticketId); //$NON-NLS-1$
	}

}