package dw.xmlrpc;

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import dw.xmlrpc.exception.DokuException;
import dw.xmlrpc.exception.DokuIncompatibleVersionException;
import dw.xmlrpc.exception.DokuMethodDoesNotExistsException;
import dw.xmlrpc.exception.DokuMisConfiguredWikiException;
import dw.xmlrpc.exception.DokuNoChangesException;
import dw.xmlrpc.exception.DokuPageDoesNotExistException;

/**
 * Main public class to actually make an xmlrpc query
 *
 * Instantiate one such client for a given wiki and a given user, then make
 * xmlrpc query using its methods.
 *
 * Most methods may throw DokuException because many things can go wrong
 * (bad url, wrong credential, no network, unreachable server, ...), so you may
 * want to make sure you handle them correcty
 */
public class DokuJClient {
	private final CoreClient _client;
	private final Locker _locker;
	private final Attacher _attacher;
	private Logger _logger;

	private final String COOKIE_PREFIX = "DW";

	/**
	 * Let override the default Logger
	 */
	public void setLogger(Logger logger){
		_logger = logger;
		_client.setLogger(logger);
	}

	/**
	 * Instantiate a client for the given user on the given wiki
	 *
	 * The wiki should be configured in a way to let this user access the
	 * xmlrpc interface
	 *
	 * @param url Should looks like http[s]://server/mywiki/lib/exe/xmlrpc.php
	 * @param user Login of the user
	 * @param password Password of the user
	 * @throws MalformedURLException
	 * @throws DokuException
	 */
    public DokuJClient(String url, String user, String password) throws MalformedURLException, DokuException{
    	this(url);
    	loginWithRetry(user, password, 2);
	}

	/**
	 * Instantiate a client for an anonymous user on the given wiki
	 *
	 * Likely to be unsuitable for most wiki since anonymous user are often
	 * not authorized to use the xmlrpc interface
	 *
	 * @param url Should looks like http[s]://server/mywiki/lib/exe/xmlrpc.php
	 * @throws MalformedURLException
	 */
	public DokuJClient(String url) throws MalformedURLException{
		this(CoreClientFactory.build(url));
	}

    public DokuJClient(DokuJClientConfig dokuConfig) throws DokuException{
    	this(CoreClientFactory.build(dokuConfig));
    	if ( dokuConfig.user() != null){
    		loginWithRetry(dokuConfig.user(), dokuConfig.password(), 2);
    	}
    }

    private DokuJClient(CoreClient client){
    	_client = client;
    	_locker = new Locker(_client);
    	_attacher = new Attacher(_client);
    	Logger logger = Logger.getLogger(DokuJClient.class.toString());
    	setLogger(logger);
    }

    public boolean hasDokuwikiCookies(){
    	for(String cookieKey : cookies().keySet()){
    		if ( cookieKey.startsWith(COOKIE_PREFIX) ){
    			return true;
    		}
    	}
    	return false;
    }

    public Map<String, String> cookies(){
    	return _client.cookies();
    }

    //Because it's been observed that some hosting services sometime mess up a bit with cookies...
    private void loginWithRetry(String user, String password, int nbMaxRetry) throws DokuException {
    	boolean success = false;
    	for(int retry=0 ; retry < nbMaxRetry && !success ; retry++ ){
    		success = login(user, password);
    	}
    }

    public Boolean login(String user, String password) throws DokuException{
    	Object[] params = new Object[]{user, password};
    	return (Boolean) genericQuery("dokuwiki.login", params) && hasDokuwikiCookies();
    }

    /**
     * Uploads a file to the wiki
     *
     * @param attachmentId Id the file should have once uploaded (eg: ns1:ns2:myfile.gif)
     * @param localPath The path to the file to upload
     * @param overwrite TRUE to overwrite if a file with this id already exist on the wiki
     * @throws IOException
     * @throws DokuException
     */
	public void putAttachment(String attachmentId, String localPath, boolean overwrite) throws IOException, DokuException{
		putAttachment(attachmentId, new File(localPath), overwrite);
	}

    /**
     * Uploads a file to the wiki
     *
     * @param attachmentId Id the file should have once uploaded (eg: ns1:ns2:myfile.gif)
     * @param localFile The file to upload
     * @param overwrite TRUE to overwrite if a file with this id already exist on the wiki
     * @throws IOException
     * @throws DokuException
     */
	public void putAttachment(String attachmentId, File localFile, boolean overwrite) throws IOException, DokuException{
		putAttachment(attachmentId, _attacher.serializeFile(localFile), overwrite);
	}

    /**
     * Uploads a file to the wiki
     *
     * @param attachmentId Id the file should have once uploaded (eg: ns1:ns2:myfile.gif)
     * @param localFile base64 encoded file
     * @param overwrite TRUE to overwrite if a file with this id already exist on the wiki
     * @throws IOException
     * @throws DokuException
     */
	public void putAttachment(String attachmentId, byte[] localFile, boolean overwrite) throws DokuException{
		_attacher.putAttachment(attachmentId, localFile, overwrite);
	}

	/**
	 * Returns information about a media file
	 *
	 * @param fileId Id of the file on the wiki (eg: ns1:ns2:myfile.gif)
	 * @throws DokuException
	 */
	public AttachmentInfo getAttachmentInfo(String fileId) throws DokuException{
		return _attacher.getAttachmentInfo(fileId);
	}

	/**
	 * Deletes a file. Fails if the file is still referenced from any page in the wiki.
	 *
	 * @param fileId Id of the file on the wiki (eg: ns1:ns2:myfile.gif)
	 * @throws DokuException
	 */
	public void deleteAttachment(String fileId) throws DokuException{
		_attacher.deleteAttachment(fileId);
	}

	/**
	 * Let download a file from the wiki
	 *
	 * @param fileId Id of the file on the wiki (eg: ns1:ns2:myfile.gif)
	 * @param localPath Where to put the file
	 * @throws DokuException
	 * @throws IOException
	 */
	public File getAttachment(String fileId, String localPath) throws DokuException, IOException{
		byte[] b = getAttachment(fileId);
		File f = new File(localPath);
		_attacher.deserializeFile(b, f);
		return f;

	}

	/**
	 * Let download a file from the wiki
	 *
	 * @param fileId Id of the file on the wiki (eg: ns1:ns2:myfile.gif)
	 * @throws DokuException
	 * @return the data of the file, encoded in base64
	 */
	public byte[] getAttachment(String fileId) throws DokuException {
		return _attacher.getAttachment(fileId);
	}

	/**
	 * Returns information about a list of media files in a given namespace
	 *
	 * @param namespace Where to look for files
	 * @throws DokuException
	 */
	public List<AttachmentDetails> getAttachments(String namespace) throws DokuException{
		return getAttachments(namespace, null);
	}

	/**
	 * Returns information about a list of media files in a given namespace
	 *
	 * @param namespace Where to look for files
	 * @param additionalParams Potential additional parameters directly sent to Dokuwiki.
	 * Available parameters are:
	 *  * recursive: TRUE if also files in subnamespaces are to be included, defaults to FALSE
	 *  * pattern: an optional PREG compatible regex which has to match the file id
	 * @throws DokuException
	 */
	public List<AttachmentDetails> getAttachments(String namespace, Map<String, Object> additionalParams) throws DokuException{
		return _attacher.getAttachments(namespace, additionalParams);
	}

	/**
	 * Returns a list of recent changed media since given timestamp
	 * @param timestamp
	 * @throws DokuException
	 */
	public List<MediaChange> getRecentMediaChanges(Integer timestamp) throws DokuException{
		return _attacher.getRecentMediaChanges(timestamp);
	}

	/**
	 * Wrapper around {@link #getRecentMediaChanges(Integer)}
	 * @param date Do not return changes older than this date
	 */
	public List<MediaChange> getRecentMediaChanges(Date date) throws DokuException {
		return getRecentMediaChanges((int)(date.getTime() / 1000));
	}

	/**
	 * Returns the current time at the remote wiki server as Unix timestamp
	 * @throws DokuException
	 */
    public Integer getTime() throws DokuException{
    	return (Integer) genericQuery("dokuwiki.getTime");
    }

    /**
     * Returns the XML RPC interface version of the remote Wiki.
     * This is DokuWiki implementation specific and independent of the supported
     * standard API version returned by wiki.getRPCVersionSupported
     * @throws DokuException
     */
    public Integer getXMLRPCAPIVersion() throws DokuException{
		return (Integer) genericQuery("dokuwiki.getXMLRPCAPIVersion");
    }

    /**
     * Returns the DokuWiki version of the remote Wiki
     * @throws DokuException
     */
	public String getVersion() throws DokuException{
		return (String) genericQuery("dokuwiki.getVersion");
	}

	/**
	 * Returns the available versions of a Wiki page.
	 *
	 * The number of pages in the result is controlled via the "recent" configuration setting of the wiki.
	 *
	 * @param pageId Id of the page (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public List<PageVersion> getPageVersions(String pageId) throws DokuException {
		return getPageVersions(pageId, 0);
	}

	/**
	 * Returns the available versions of a Wiki page.
	 *
	 * The number of pages in the result is controlled via the recent configuration setting of the wiki.
	 *
	 * @param pageId Id of the page (eg: ns1:ns2:mypage)
	 * @param offset Can be used to list earlier versions in the history.
	 * @throws DokuException
	 */
	public List<PageVersion> getPageVersions(String pageId, Integer offset) throws DokuException {
		boolean useFixForLegacyWiki = false;
		if ( offset > 0 ){
			if ( getXMLRPCAPIVersion() < 10 ){
				useFixForLegacyWiki = true;
				offset--;
			}
		}
		Object[] params = new Object[]{pageId, offset};
		Object[] result = (Object[]) genericQuery("wiki.getPageVersions", params);
		if ( useFixForLegacyWiki && result.length > 1 ){
			result = Arrays.copyOfRange(result, 1, result.length);
		}
		return ObjectConverter.toPageVersion(result, pageId);
	}

	/**
	 * Returns the raw Wiki text for a specific revision of a Wiki page.
	 * @param pageId Id of the page (eg: ns1:ns2:mypage)
	 * @param timestamp Version of the page
	 * @throws DokuException
	 */
	public String getPageVersion(String pageId, Integer timestamp) throws DokuException{
		Object[]params = new Object[]{pageId, timestamp};
		return (String) genericQuery("wiki.getPageVersion", params);
	}

	/**
	 * Lists all pages within a given namespace
	 * @param namespace Namespace to look for (eg: ns1:ns2)
	 * @throws DokuException
	 */
	public List<PageDW> getPagelist(String namespace) throws DokuException {
		return getPagelist(namespace, null);
	}

	/**
	 * Lists all pages within a given namespace
	 * @param namespace Namespace to look for (eg: ns1:ns2)
	 * @param options Options passed directly to dokuwiki's search_all_pages()
	 * @throws DokuException
	 */
	public List<PageDW> getPagelist(String namespace, Map<String, Object> options) throws DokuException {
		List<Object> params = new ArrayList<Object>();
		params.add(namespace);
		params.add(ensureWeComputeThePageHash(options));

		Object result = genericQuery("dokuwiki.getPagelist", params.toArray());
		return ObjectConverter.toPageDW((Object[]) result);
	}

	private Map<String, Object> ensureWeComputeThePageHash(Map<String, Object> initialOptions){
		Map<String, Object> result;
		if ( initialOptions == null ){
			result = new HashMap<String, Object>();
		} else {
			result = new HashMap<String, Object>(initialOptions);
		}
		if ( !result.containsKey("hash") ){
			result.put("hash", true);
		}
		return result;
	}

	/**
	 * Returns the permission of the given wikipage.
	 * @param pageId Id of the page (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public Integer aclCheck(String pageId) throws DokuException{
		Object res = _client.genericQuery("wiki.aclCheck", pageId);
		return ObjectConverter.toPerms(res);
	}

	/**
	 * Returns the supported RPC API version
	 *
	 * cf http://www.jspwiki.org/wiki/WikiRPCInterface2 for more info
	 * @throws DokuException
	 */
	public Integer getRPCVersionSupported() throws DokuException{
		return (Integer) genericQuery("wiki.getRPCVersionSupported");
	}

	/**
	 * Allows to lock or unlock a whole bunch of pages at once.
	 * Useful when you are about to do a operation over multiple pages
	 * @param pagesToLock  Ids of pages to lock
	 * @param pagesToUnlock Ids of pages to unlock
	 * @throws DokuException
	 */
	public LockResult setLocks(List<String> pagesToLock, List<String> pagesToUnlock) throws DokuException{
		return _locker.setLocks(pagesToLock, pagesToUnlock);
	}

	/**
	 * Lock a page
	 * @param pageId Id of the page to lock (eg: ns1:ns2:mypage)
	 * @return TRUE the page has been successfully locked, FALSE otherwise
	 * @throws DokuException
	 */
	public boolean lock(String pageId) throws DokuException{
		return _locker.lock(pageId).locked().contains(pageId);
	}

	/**
	 * Unlock a page
	 * @param pageId Id of the page to unlock (eg: ns1:ns2:mypage)
	 * @return TRUE the page has been successfully unlocked, FALSE otherwise
	 * @throws DokuException
	 */
	public boolean unlock(String pageId) throws DokuException{
		return _locker.unlock(pageId).unlocked().contains(pageId);
	}

	/**
	 * Returns the title of the wiki
	 * @throws DokuException
	 */
	public String getTitle() throws DokuException{
		return (String) genericQuery("dokuwiki.getTitle");
	}

	/**
	 * Appends text to a Wiki Page.
	 * @param pageId Id of the page to edit (eg: ns1:ns2:mypage)
	 * @param rawWikiText Text to add to the current page content
	 * @throws DokuException
	 */
	public void appendPage(String pageId, String rawWikiText) throws DokuException {
		appendPage(pageId, rawWikiText, null);
	}

	/**
	 * Appends text to a Wiki Page.
	 * @param pageId Id of the page to edit (eg: ns1:ns2:mypage)
	 * @param rawWikiText Text to add to the current page content
	 * @param summary A summary of the modification
	 * @param minor Whether it's a minor modification
	 * @throws DokuException
	 */
	public void appendPage(String pageId, String rawWikiText, String summary, boolean minor) throws DokuException {
		Map<String, Object> options = new HashMap<String, Object>();
		options.put("sum",  summary);
		options.put("minor", minor);

		appendPage(pageId, rawWikiText, options);
	}

	/**
	 * Appends text to a Wiki Page.
	 * @param pageId Id of the page to edit (eg: ns1:ns2:mypage)
	 * @param rawWikiText Text to add to the current page content
	 * @param options Options passed to Dokuwiki. ie: 'sum' and/or 'minor'
	 * @throws DokuException
	 */
	public void appendPage(String pageId, String rawWikiText, Map<String, Object> options) throws DokuException {
		if ( options == null ){
			options = new HashMap<String, Object>();
		}
		genericQuery("dokuwiki.appendPage", new Object[]{pageId, rawWikiText, options});
	}

	/**
	 * Returns the raw Wiki text for a page
	 * @param pageId Id of the page to fetch (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public String getPage(String pageId) throws DokuException {
		return (String) genericQuery("wiki.getPage", pageId);
	}

	/**
	 * Saves a Wiki Page
	 * @param pageId Id of the page to save
	 * @param rawWikiText Text to put
	 * @throws DokuException
	 */
	public void putPage(String pageId, String rawWikiText)throws DokuException {
		putPage(pageId, rawWikiText, null);
	}

	/**
	 * Saves a Wiki Page
	 * @param pageId Id of the page to save
	 * @param rawWikiText Text to put
	 * @param summary Summary of the edition
	 * @param minor Whether it's a minor edition
	 * @throws DokuException
	 */
	public void putPage(String pageId, String rawWikiText, String summary, boolean minor) throws DokuException{
		Map<String, Object> options = new HashMap<String, Object>();
		options.put("sum",  summary);
		options.put("minor", minor);
		putPage(pageId, rawWikiText, options);
	}

	/**
	 * Saves a Wiki Page
	 * @param pageId Id of the page to save
	 * @param rawWikiText Text to put
	 * @param options Options passed to Dokuwiki. ie: 'sum' and/or 'minor'	 * @throws DokuException
	 */
	public void putPage(String pageId, String rawWikiText, Map<String, Object> options)throws DokuException {
		if (options == null){
			options = new HashMap<String, Object>();
		}
		genericQuery("wiki.putPage", new Object[]{pageId, rawWikiText, options});
	}

	/**
	 * Performs a fulltext search
	 * @param pattern A query string as described on https://www.dokuwiki.org/search
	 * @return Matching pages. Snippets are provided for the first 15 results.
	 * @throws DokuException
	 */
	public List<SearchResult> search(String pattern) throws DokuException{
		Object[] results = (Object[]) genericQuery("dokuwiki.search", pattern);
		return ObjectConverter.toSearchResult(results);
	}

	/**
	 * Returns information about a Wiki page
	 * @param pageId Id of the page wanted (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public PageInfo getPageInfo(String pageId) throws DokuException{
		try {
			Object result = genericQuery("wiki.getPageInfo",pageId);
			return ObjectConverter.toPageInfo(result);
		} catch(DokuMisConfiguredWikiException e){
			//Because "Adora Belle" (DW-2013-05-10) seems to have a bug with this command when the page doesn't exist
			if ( ! isConfiguredToAcceptXmlRpcQueries() ){
				throw e;
			}
			throw new DokuPageDoesNotExistException(null);
		}
	}

	/**
	 * Returns information about a specific version of a Wiki page
	 * @param pageId Id of the page wanted(eg: ns1:ns2:mypage)
	 * @param timestamp version wanted
	 * @throws DokuException
	 */
	public PageInfo getPageInfoVersion(String pageId, Integer timestamp) throws DokuException {
		Object[] params = new Object[]{pageId, timestamp};
		Object result = genericQuery("wiki.getPageInfoVersion", params);
		return ObjectConverter.toPageInfo(result);
	}

	/**
	 * Returns a list of all Wiki pages in the remote Wiki
	 * @throws DokuException
	 */
	public 	List<Page> getAllPages() throws DokuException {
		Object result = genericQuery("wiki.getAllPages");
		return ObjectConverter.toPage((Object[]) result);
	}

	/**
	 * Returns a list of backlinks of a Wiki page
	 * @param pageId Id of the page wanted (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public List<String> getBackLinks(String pageId) throws DokuException{
		Object result = genericQuery("wiki.getBackLinks", pageId);
		return ObjectConverter.toString((Object[]) result);
	}

	/**
	 * Returns the rendered XHTML body of a Wiki page
	 * @param pageId Id of the wanted page (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public String getPageHTML(String pageId) throws DokuException {
		return (String) genericQuery("wiki.getPageHTML", pageId);
	}

	/**
	 * Returns the rendered HTML of a specific version of a Wiki page
	 * @param pageId Id of the wanted page (eg: ns1:ns2:mypage)
	 * @param timestamp Version wanted
	 * @throws DokuException
	 */
	public String getPageHTMLVersion(String pageId, Integer timestamp) throws DokuException{
		Object[] params = new Object[]{pageId, timestamp};
		return (String) genericQuery("wiki.getPageHTMLVersion", params);
	}

	/**
	 * Returns a list of all links contained in a Wiki page
	 * @param pageId Id of the wanted page (eg: ns1:ns2:mypage)
	 * @throws DokuException
	 */
	public List<LinkInfo> listLinks(String pageId) throws DokuException {
		Object result = genericQuery("wiki.listLinks", pageId);
		return ObjectConverter.toLinkInfo((Object[]) result);
	}

	/**
	 * Returns a list of recent changes since a given timestamp
	 *
	 * According to Dokuwiki documentation (https://www.dokuwiki.org/recent_changes):
	 *
	 * * Only the most recent change for each page is listed, regardless of how many times that page was changed.
	 * * The number of changes shown per page is controlled by the "recent" setting.
	 * * Users are only shown pages to which they have read access
	 *
	 * @param timestamp Do not return changes older than this timestamp
	 * @throws DokuException
	 */
	public List<PageChange> getRecentChanges(Integer timestamp) throws DokuException{
		Object result;
		try {
			result = genericQuery("wiki.getRecentChanges", timestamp);
		} catch (DokuNoChangesException e){
			return new ArrayList<PageChange>();
		}

		Object[] pageChanges;
		try {
			pageChanges = (Object[]) result;
		} catch (ClassCastException e){
			//It likely happens when there are no changes, with only a few versions of DW
			//(newer versions yield a DokuNoChangesException instead)
			//Hence it might be enough to just return an empty list... but in doubt I'd rather cast
			@SuppressWarnings("unchecked")
			Map<String, Object> pageChangesMap = (Map<String, Object>) result;
			pageChanges = pageChangesMap.values().toArray();
		}
		return ObjectConverter.toPageChange(pageChanges);
	}

	/**
	 * Wrapper around {@link #getRecentChanges(Integer)}
	 * @param date Do not return changes older than this date
	 */
	public List<PageChange> getRecentChanges(Date date) throws DokuException {
		return getRecentChanges((int)(date.getTime() / 1000));
	}

	/**
	 * Tries to logoff by expiring auth cookies and the associated PHP session
	 */
	public void logoff() throws DokuException {
		try {
			_client.genericQuery("dokuwiki.logoff");
		} catch(DokuMethodDoesNotExistsException e){
			if (_logger != null){
				_logger.log(Level.WARNING,
						"This Dokuwiki instance doesn't support 'logoff' (api version < 9). " +
						"We're clearing the cookies of this client, but we can't destroy the server side php session");
			}
		}
		_client.clearCookies();
	}

	/**
	 * Only available for dokuwiki-2013-12-08 (Binky) or newer
	 */
	public boolean addAcl(String scope, String username, int permission) throws DokuException{
		try {
			return (Boolean) genericQuery("plugin.acl.addAcl", new Object[]{scope, username, permission});
		} catch (DokuMethodDoesNotExistsException e){
			throw new DokuIncompatibleVersionException("dokuwiki-2013-12-08 (Binky)");
		}
	}

	/**
	 * Only available for dokuwiki-2013-12-08 (Binky) or newer
	 */
	public boolean delAcl(String scope, String username) throws DokuException{
		try {
			return (Boolean) genericQuery("plugin.acl.delAcl", new Object[]{scope, username});
		} catch (DokuMethodDoesNotExistsException e){
			throw new DokuIncompatibleVersionException("dokuwiki-2013-12-08 (Binky)");
		}
	}

	/**
	 * Let execute any xmlrpc query without argument
	 * @param action The name of the xmlrpc method to invoke
	 * @return Whatever the xmlrpc should return, as an Object
	 * @throws DokuException
	 */
	public Object genericQuery(String action) throws DokuException {
		return _client.genericQuery(action);
	}

	/**
	 * Let execute any xmlrpc query with one argument
	 * @param action The name of the xmlrpc method to invoke
	 * @param param The unique parameter, as an Object
	 * @return Whatever the xmlrpc should return, as an Object
	 * @throws DokuException
	 */
	public Object genericQuery(String action, Object param) throws DokuException{
		return _client.genericQuery(action, param);
	}

	/**
	 * Let execute any xmlrpc query with an arbitrary number of arguments
	 * @param action The name of the xmlrpc method to invoke
	 * @param params The parameters, as an array of Objects
	 * @return Whatever the xmlrpc should return, as an Object
	 * @throws DokuException
	 */
	public Object genericQuery(String action, Object[] params) throws DokuException{
		return _client.genericQuery(action, params);
	}

	private boolean isConfiguredToAcceptXmlRpcQueries() throws DokuException{
		try {
			getTitle();
		} catch(DokuMisConfiguredWikiException e){
			return false;
		}
		return true;
	}
}
