/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License") +  you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.openmeetings.service.calendar.caldav;

import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static javax.servlet.http.HttpServletResponse.SC_OK;

import java.io.IOException;
import java.net.URI;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;

import javax.annotation.PreDestroy;

import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.jackrabbit.webdav.DavConstants;
import org.apache.jackrabbit.webdav.MultiStatusResponse;
import org.apache.jackrabbit.webdav.client.methods.HttpPropfind;
import org.apache.jackrabbit.webdav.property.DavProperty;
import org.apache.jackrabbit.webdav.property.DavPropertyName;
import org.apache.jackrabbit.webdav.property.DavPropertyNameSet;
import org.apache.jackrabbit.webdav.property.DavPropertySet;
import org.apache.jackrabbit.webdav.xml.DomUtil;
import org.apache.openmeetings.db.dao.calendar.AppointmentDao;
import org.apache.openmeetings.db.dao.calendar.OmCalendarDao;
import org.apache.openmeetings.db.entity.calendar.Appointment;
import org.apache.openmeetings.db.entity.calendar.OmCalendar;
import org.apache.openmeetings.db.entity.calendar.OmCalendar.SyncType;
import org.apache.openmeetings.service.calendar.caldav.handler.CalendarHandler;
import org.apache.openmeetings.service.calendar.caldav.handler.CtagHandler;
import org.apache.openmeetings.service.calendar.caldav.handler.EtagsHandler;
import org.apache.openmeetings.service.calendar.caldav.handler.WebDAVSyncHandler;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.w3c.dom.Element;

import com.github.caldav4j.CalDAVConstants;

import net.fortuna.ical4j.util.MapTimeZoneCache;

/**
 * Class which does syncing and provides respective API's required for performing CalDAV Operations.
 * @author Ankush Mishra ([email protected])
 */
@Component
public class AppointmentManager {
	private static final Logger log = LoggerFactory.getLogger(AppointmentManager.class);

	//HttpClient and ConnectionManager Params
	private static final int IDLE_CONNECTION_TIMEOUT = 30; // 30 seconds
	private static final int MAX_HOST_CONNECTIONS = 6; // Number of simultaneous connections to one host
	private static final int MAX_TOTAL_CONNECTIONS = 10; // Max Connections, at one time in memory.

	private PoolingHttpClientConnectionManager connmanager = null;

	static {
		// Disable TimeZone caching through JCache
		System.setProperty("net.fortuna.ical4j.timezone.cache.impl", MapTimeZoneCache.class.getName());
	}

	@Autowired
	private OmCalendarDao calendarDao;
	@Autowired
	private AppointmentDao appointmentDao;
	@Autowired
	private IcalUtils utils;

	/**
	 * Returns a new HttpClient with the inbuilt connection manager in this.
	 *
	 * @return HttpClient object that was created.
	 */
	public HttpClient createHttpClient() {
		if (connmanager == null) {
			connmanager = new PoolingHttpClientConnectionManager();
			connmanager.setDefaultMaxPerRoute(MAX_HOST_CONNECTIONS);
			connmanager.setMaxTotal(MAX_TOTAL_CONNECTIONS);
		}

		return HttpClients.custom()
				.setConnectionManager(connmanager)
				.build();
	}

	/**
	 * Ensure the URL ends with a, trailing slash, i.e. "/"
	 *
	 * @param str String URL to check.
	 * @return String which has a trailing slash.
	 */
	private static String ensureTrailingSlash(String str) {
		return str.endsWith("/") || str.endsWith("\\") ? str : str + "/";
	}

	/**
	 * Adds the Credentials provided to the given client on the Calendar's URL.
	 *
	 * @param context     Context of the Client which makes the connection.
	 * @param calendar    Calendar whose Host the Credentials are for.
	 * @param credentials Credentials to add
	 */
	public void provideCredentials(HttpClientContext context, OmCalendar calendar, Credentials credentials) {
		// Done through creating a new Local context
		if (!Strings.isEmpty(calendar.getHref()) && credentials != null) {
			URI temp = URI.create(calendar.getHref());
			context.getCredentialsProvider().setCredentials(new AuthScope(temp.getHost(), temp.getPort()), credentials);
		}
	}

	/**
	 * Tests if the Calendar's URL can be accessed, or not.
	 *
	 * @param client   Client which makes the connection.
	 * @param context http context
	 * @param calendar Calendar whose URL is to be accessed.
	 * @return Returns true for HTTP Status 200, or 204, else false.
	 */
	public boolean testConnection(HttpClient client, HttpClientContext context, OmCalendar calendar) {
		cleanupIdleConnections();

		HttpOptions optionsMethod = null;
		try {
			String path = calendar.getHref();
			optionsMethod = new HttpOptions(path);
			optionsMethod.setHeader("Accept", "*/*");
			HttpResponse response = client.execute(optionsMethod, context);
			int status = response.getStatusLine().getStatusCode();
			if (status == SC_OK || status == SC_NO_CONTENT) {
				return true;
			}
		} catch (IOException e) {
			log.error("Error executing OptionsMethod during testConnection.", e);
		} catch (Exception e) {
			//Should not ever reach here.
			log.error("Severe Error in executing OptionsMethod during testConnection.", e);
		} finally {
			if (optionsMethod != null) {
				optionsMethod.reset();
			}
		}
		return false;
	}

	/**
	 * Create or Update calendar on the database.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param calendar - calendar to be created
	 * @return <code>true</code> if calendar was created/updated
	 */
	public boolean createCalendar(HttpClient client, HttpClientContext context, OmCalendar calendar) {
		if (calendar.getId() == null && calendar.getSyncType() != SyncType.GOOGLE_CALENDAR) {
			return discoverCalendars(client, context, calendar);
		}
		calendarDao.update(calendar);
		return true;
	}

	/**
	 * Deletes the calendar from the local database.
	 *
	 * @param calendar Calendar to delete
	 */
	public void deleteCalendar(OmCalendar calendar) {
		calendarDao.delete(calendar);
	}

	public List<OmCalendar> getCalendars() {
		return calendarDao.get();
	}

	/**
	 * Method to get user's calendars
	 * please see {@link OmCalendarDao#getByUser(Long)}
	 *
	 * @param userid - id of the user
	 * @return the list of the calendars
	 */
	public List<OmCalendar> getCalendars(Long userid) {
		return calendarDao.getByUser(userid);
	}

	/**
	 * Method to get user's google calendars
	 * please see {@link OmCalendarDao#getGoogleCalendars(Long)}
	 *
	 * @param userId - id of the user
	 * @return the list of the calendars
	 */
	public List<OmCalendar> getGoogleCalendars(Long userId) {
		return calendarDao.getGoogleCalendars(userId);
	}

	/**
	 * Function which when called performs syncing based on the type of Syncing detected.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param calendar Calendar who's sync has to take place
	 */
	public void syncItem(HttpClient client, HttpClientContext context, OmCalendar calendar) {
		cleanupIdleConnections();

		if (calendar.getSyncType() != SyncType.NONE) {
			CalendarHandler calendarHandler;
			String path = calendar.getHref();

			switch (calendar.getSyncType()) {
				case WEBDAV_SYNC:
					calendarHandler = new WebDAVSyncHandler(path, calendar, client, context, appointmentDao, utils);
					break;
				case CTAG:
					calendarHandler = new CtagHandler(path, calendar, client, context, appointmentDao, utils);
					break;
				case ETAG:
				default: //Default is the EtagsHandler.
					calendarHandler = new EtagsHandler(path, calendar, client, context, appointmentDao, utils);
					break;
			}

			calendarHandler.syncItems();
			calendarDao.update(calendar);
		}
	}

	/**
	 * Syncs all the calendars currrently present on the DB.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param userId - id of the user
	 */
	public void syncItems(HttpClient client, HttpClientContext context, Long userId) {
		List<OmCalendar> calendars = getCalendars(userId);
		for (OmCalendar calendar : calendars) {
			syncItem(client, context, calendar);
		}
	}

	/**
	 * Function which finds all the calendars of the Principal URL of the calendar
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param calendar - calendar to get principal URL from
	 * @return - <code>true</code> in case calendar was discovered successfully
	 */
	private boolean discoverCalendars(HttpClient client, HttpClientContext context, OmCalendar calendar) {
		cleanupIdleConnections();

		if (calendar.getSyncType() != SyncType.NONE) {
			return false;
		}
		HttpPropfind propFindMethod = null;
		String userPath = null, homepath = null;

		DavPropertyName curUserPrincipal = DavPropertyName.create("current-user-principal"),
				calHomeSet = DavPropertyName.create("calendar-home-set", CalDAVConstants.NAMESPACE_CALDAV),
				suppCalCompSet = DavPropertyName.create("supported-calendar-component-set", CalDAVConstants.NAMESPACE_CALDAV);

		//Find out whether it's a calendar or if we can find the calendar-home or current-user url
		try {
			String path = calendar.getHref();

			DavPropertyNameSet properties = new DavPropertyNameSet();
			properties.add(curUserPrincipal);
			properties.add(calHomeSet);
			properties.add(DavPropertyName.RESOURCETYPE);

			propFindMethod = new HttpPropfind(path, properties, CalDAVConstants.DEPTH_0);
			HttpResponse httpResponse = client.execute(propFindMethod, context);

			if (!propFindMethod.succeeded(httpResponse)) {
				return false;
			}
			for (MultiStatusResponse response : propFindMethod.getResponseBodyAsMultiStatus(httpResponse).getResponses()) {
				DavPropertySet set = response.getProperties(SC_OK);
				DavProperty<?> calhome = set.get(calHomeSet), curPrinci = set.get(curUserPrincipal),
						resourcetype = set.get(DavPropertyName.RESOURCETYPE);

				if (checkCalendarResourceType(resourcetype)) {
					//This is a calendar and thus initialize and return
					return initCalendar(client, context, calendar);
				}

				//Else find all the calendars on the Principal and return.
				if (calhome != null) {
					//Calendar Home Path
					homepath = getTextValuefromProperty(calhome);
					break;
				} else if (curPrinci != null) {
					//Current User Principal Path
					userPath = getTextValuefromProperty(curPrinci);
					break;
				}
			}

			if (homepath == null && userPath != null) {
				//If calendar home path wasn't set, then we get it
				DavPropertyNameSet props = new DavPropertyNameSet();
				props.add(calHomeSet);
				propFindMethod = new HttpPropfind(userPath, props, DavConstants.DEPTH_0);
				httpResponse = client.execute(propFindMethod, context);

				if (!propFindMethod.succeeded(httpResponse)) {
					return false;
				}
				for (MultiStatusResponse response : propFindMethod.getResponseBodyAsMultiStatus(httpResponse).getResponses()) {
					DavPropertySet set = response.getProperties(SC_OK);
					DavProperty<?> calhome = set.get(calHomeSet);

					if (calhome != null) {
						homepath = getTextValuefromProperty(calhome);
						break;
					}
				}
			}

			if (homepath != null) {
				DavPropertyNameSet props = new DavPropertyNameSet();
				props.add(DavPropertyName.RESOURCETYPE);
				props.add(suppCalCompSet);
				props.add(DavPropertyName.DISPLAYNAME);

				propFindMethod = new HttpPropfind(homepath, props, DavConstants.DEPTH_1);

				httpResponse = client.execute(propFindMethod, context);

				if (propFindMethod.succeeded(httpResponse)) {
					boolean success = false;

					URI resourceUri = propFindMethod.getURI();
					String host = resourceUri.getScheme() + "://" + resourceUri.getHost() + ((resourceUri.getPort() != -1)? ":" + resourceUri.getPort() : "");
					for (MultiStatusResponse response : propFindMethod.getResponseBodyAsMultiStatus(httpResponse).getResponses()) {
						boolean isVevent = false, isCalendar;

						DavPropertySet set = response.getProperties(SC_OK);
						DavProperty<?> p = set.get(suppCalCompSet),
								resourcetype = set.get(DavPropertyName.RESOURCETYPE),
								displayname = set.get(DavPropertyName.DISPLAYNAME);

						isCalendar = checkCalendarResourceType(resourcetype);

						if (p != null) {
							for (Object o : (Collection<?>) p.getValue()) {
								if (o instanceof Element) {
									Element e = (Element) o;
									String name = DomUtil.getAttribute(e, "name", null);
									if ("VEVENT".equals(name)) {
										isVevent = true;
									}
								}
							}
						}

						if (isCalendar && isVevent) {
							success = true;
							//Get New Calendar
							OmCalendar tempCalendar = new OmCalendar();

							if (displayname != null) {
								tempCalendar.setTitle(displayname.getValue().toString());
							}

							tempCalendar.setHref(host + response.getHref());

							tempCalendar.setDeleted(false);
							tempCalendar.setOwner(calendar.getOwner());

							calendarDao.update(tempCalendar);
							initCalendar(client, context, tempCalendar);
						}
					}
					return success;
				}
			}

		} catch (IOException e) {
			log.error("Error executing PROPFIND Method, during testConnection.", e);
		} catch (Exception e) {
			log.error("Severe Error in executing PROPFIND Method, during testConnection.", e);
		} finally {
			if (propFindMethod != null) {
				propFindMethod.reset();
			}
		}

		return false;
	}

	private static String getTextValuefromProperty(DavProperty<?> property) {
		String value = null;

		if (property != null) {
			for (Object o : (Collection<?>) property.getValue()) {
				if (o instanceof Element) {
					Element e = (Element) o;
					value = DomUtil.getTextTrim(e);
					break;
				}
			}
		}
		return value;
	}

	/**
	 * Returns true if the resourcetype Property has a Calendar Element under it.
	 *
	 * @param resourcetype ResourceType Property
	 * @return True if, resource is Calendar, else false.
	 */
	private static boolean checkCalendarResourceType(DavProperty<?> resourcetype) {
		boolean isCalendar = false;

		if (resourcetype != null) {
			DavPropertyName calProp = DavPropertyName.create("calendar", CalDAVConstants.NAMESPACE_CALDAV);

			for (Object o : (Collection<?>) resourcetype.getValue()) {
				if (o instanceof Element) {
					Element e = (Element) o;
					if (e.getLocalName().equals(calProp.getName())) {
						isCalendar = true;
					}
				}
			}
		}
		return isCalendar;
	}

	/**
	 * Function to initialize the Calendar on the type of syncing and whether it can be used or not.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param calendar - calendar to be inited
	 * @return <code>true</code> in case calendar was inited
	 */
	private boolean initCalendar(HttpClient client, HttpClientContext context, OmCalendar calendar) {

		if (calendar.getToken() == null || calendar.getSyncType() == SyncType.NONE) {
			calendarDao.update(calendar);

			HttpPropfind propFindMethod = null;

			try {
				String path = calendar.getHref();

				DavPropertyNameSet properties = new DavPropertyNameSet();
				properties.add(DavPropertyName.RESOURCETYPE);
				properties.add(DavPropertyName.DISPLAYNAME);
				properties.add(CtagHandler.DNAME_GETCTAG);
				properties.add(WebDAVSyncHandler.DNAME_SYNCTOKEN);

				propFindMethod = new HttpPropfind(path, properties, CalDAVConstants.DEPTH_0);
				HttpResponse httpResponse = client.execute(propFindMethod, context);

				if (propFindMethod.succeeded(httpResponse)) {

					for (MultiStatusResponse response : propFindMethod.getResponseBodyAsMultiStatus(httpResponse).getResponses()) {
						DavPropertySet set = response.getProperties(SC_OK);

						if (calendar.getTitle() == null) {
							DavProperty<?> property = set.get(DavPropertyName.DISPLAYNAME);
							calendar.setTitle(property == null ? null : property.getValue().toString());
						}

						DavProperty<?> ctag = set.get(CtagHandler.DNAME_GETCTAG),
								syncToken = set.get(WebDAVSyncHandler.DNAME_SYNCTOKEN);
						if (syncToken != null) {
							calendar.setSyncType(SyncType.WEBDAV_SYNC);
						} else if (ctag != null) {
							calendar.setSyncType(SyncType.CTAG);
						} else {
							calendar.setSyncType(SyncType.ETAG);
						}
					}

					syncItem(client, context, calendar);
					return true;
				} else {
					log.error("Error executing PROPFIND Method, with status Code: {}", httpResponse.getStatusLine().getStatusCode());
					calendar.setSyncType(SyncType.NONE);
				}

			} catch (IOException e) {
				log.error("Error executing OptionsMethod during testConnection.", e);
			} catch (Exception e) {
				log.error("Severe Error in executing OptionsMethod during testConnection.", e);
			} finally {
				if (propFindMethod != null) {
					propFindMethod.reset();
				}
			}
		}

		return false;
	}

	/**
	 * Function for create/updating multiple appointment on the server.
	 * Performs modification alongside of creation new events on the server.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param appointment Appointment to create/update.
	 * @return <code>true</code> in case item was updated
	 */
	public boolean updateItem(HttpClient client, HttpClientContext context, Appointment appointment) {
		cleanupIdleConnections();

		OmCalendar calendar = appointment.getCalendar();
		SyncType type = calendar.getSyncType();
		if (type != SyncType.NONE && type != SyncType.GOOGLE_CALENDAR) {
			CalendarHandler calendarHandler;
			String path = ensureTrailingSlash(calendar.getHref());

			switch (type) {
				case WEBDAV_SYNC:
				case CTAG:
				case ETAG:
					calendarHandler = new EtagsHandler(path, calendar, client, context, appointmentDao, utils);
					break;
				default:
					return false;
			}
			return calendarHandler.updateItem(appointment);
		}
		return false;
	}

	/**
	 * Delete Appointment on the CalDAV server.
	 * Delete's on the Server only if the ETag of the Appointment is the one on the server,
	 * i.e. only if the Event hasn't changed on the Server.
	 *
	 * @param client - {@link HttpClient} to discover calendar
	 * @param context http context
	 * @param appointment Appointment to Delete
	 * @return <code>true</code> in case item was deleted
	 */
	public boolean deleteItem(HttpClient client, HttpClientContext context, Appointment appointment) {
		cleanupIdleConnections();

		OmCalendar calendar = appointment.getCalendar();
		SyncType type = calendar.getSyncType();

		if (type != SyncType.NONE && type != SyncType.GOOGLE_CALENDAR) {
			CalendarHandler calendarHandler;
			String path = calendar.getHref();

			switch (type) {
				case WEBDAV_SYNC:
				case CTAG:
				case ETAG:
					calendarHandler = new EtagsHandler(path, calendar, client, context, appointmentDao, utils);
					break;
				default:
					return false;
			}

			return calendarHandler.deleteItem(appointment);
		}
		return false;
	}

	/**
	 * Returns the String value of the property, else null.
	 *
	 * @param property Property who's string value is to be returned.
	 * @return String representation of the Property Value.
	 */
	public static String getTokenFromProperty(DavProperty<?> property) {
		return (property == null) ? null : property.getValue().toString();
	}

	/**
	 * Cleans up unused idle connections.
	 */
	public void cleanupIdleConnections() {
		if (connmanager != null) {
			connmanager.closeIdleConnections(IDLE_CONNECTION_TIMEOUT, TimeUnit.SECONDS);
		}
	}

	/**
	 * Method which is called when the Context is destroyed.
	 */
	@PreDestroy
	public void destroy() {
		if (connmanager != null) {
			connmanager.shutdown();
			connmanager = null;
		}
	}
}