package org.ligoj.app.plugin.km.confluence;

import java.io.IOException;
import java.net.MalformedURLException;
import java.text.Format;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.xml.bind.DatatypeConverter;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.ligoj.app.api.SubscriptionStatusWithData;
import org.ligoj.app.dao.NodeRepository;
import org.ligoj.app.iam.IamProvider;
import org.ligoj.app.iam.SimpleUser;
import org.ligoj.app.plugin.km.KmResource;
import org.ligoj.app.plugin.km.KmServicePlugin;
import org.ligoj.app.resource.NormalizeFormat;
import org.ligoj.app.resource.plugin.AbstractToolPluginResource;
import org.ligoj.app.resource.plugin.CurlProcessor;
import org.ligoj.app.resource.plugin.CurlRequest;
import org.ligoj.app.resource.plugin.VersionUtils;
import org.ligoj.bootstrap.core.json.InMemoryPagination;
import org.ligoj.bootstrap.core.security.SecurityHelper;
import org.ligoj.bootstrap.core.validation.ValidationJsonException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * Confluence KM resource.
 * 
 * @see "https://docs.atlassian.com/atlassian-confluence/REST/latest"
 */
@Path(ConfluencePluginResource.URL)
@Service
@Produces(MediaType.APPLICATION_JSON)
public class ConfluencePluginResource extends AbstractToolPluginResource implements KmServicePlugin {

	/**
	 * Space activity pattern for HTML markup.
	 */
	private static final Pattern ACTIVITY_PATTERN = Pattern.compile(
			"logo\"\\s*src=\"([^\"]+)\".*data-username=\"([^\"]+)\"[^>]+>([^<]+)<.*href=\"([^\"]+)\"[^>]*>([^<]+)<.*update-item-date\">([^<]+)<",
			Pattern.DOTALL);

	/**
	 * Plug-in key.
	 */
	public static final String URL = KmResource.SERVICE_URL + "/confluence";

	/**
	 * Plug-in key.
	 */
	public static final String KEY = URL.replace('/', ':').substring(1);

	/**
	 * Web site URL
	 */
	public static final String PARAMETER_URL = KEY + ":url";

	/**
	 * Confluence space KEY (not name).
	 */
	public static final String PARAMETER_SPACE = KEY + ":space";

	/**
	 * Confluence user name able to perform index.
	 */
	public static final String PARAMETER_USER = KEY + ":user";

	/**
	 * Confluence user password able to perform index.
	 */
	public static final String PARAMETER_PASSWORD = KEY + ":password";

	/**
	 * Jackson type reference for Confluence space
	 */
	private static final TypeReference<Map<String, Object>> TYPE_SPACE_REF = new TypeReference<Map<String, Object>>() {
		// Nothing to override
	};

	@Autowired
	private InMemoryPagination inMemoryPagination;

	@Autowired
	protected IamProvider[] iamProvider;

	@Autowired
	protected VersionUtils versionUtils;

	@Autowired
	private NodeRepository nodeRepository;

	@Autowired
	private SecurityHelper securityHelper;

	@Autowired
	private ObjectMapper objectMapper;

	/**
	 * Check the server is available.
	 */
	private void validateAccess(final Map<String, String> parameters) throws Exception {
		if (getVersion(parameters) == null) {
			throw new ValidationJsonException(PARAMETER_URL, "confluence-connection");
		}
	}

	/**
	 * Prepare an authenticated connection to Confluence
	 */
	protected void authenticate(final Map<String, String> parameters, final CurlProcessor processor) {
		final String user = parameters.get(PARAMETER_USER);
		final String password = StringUtils.trimToEmpty(parameters.get(PARAMETER_PASSWORD));
		final String url = StringUtils.appendIfMissing(parameters.get(PARAMETER_URL), "/") + "dologin.action";
		final List<CurlRequest> requests = new ArrayList<>();
		requests.add(new CurlRequest(HttpMethod.GET, url, null));
		requests.add(new CurlRequest(HttpMethod.POST, url,
				"os_username=" + user + "&os_password=" + password + "&os_destination=&atl_token=&login=Connexion",
				ConfluenceCurlProcessor.LOGIN_CALLBACK, "Accept:text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"));
		if (!processor.process(requests)) {
			throw new ValidationJsonException(PARAMETER_URL, "confluence-login", parameters.get(PARAMETER_USER));
		}
	}

	/**
	 * Validate the administration connectivity. Expect an authenticated
	 * connection.
	 */
	private void validateAdminAccess(final Map<String, String> parameters, final CurlProcessor processor) {
		final List<CurlRequest> requests = new ArrayList<>();

		// Request plugins access
		final String url = parameters.get(PARAMETER_URL);
		requests.add(new CurlRequest(HttpMethod.GET, StringUtils.appendIfMissing(url, "/") + "plugins/servlet/upm", null));
		if (!processor.process(requests)) {
			throw new ValidationJsonException(PARAMETER_URL, "confluence-admin", parameters.get(PARAMETER_USER));
		}
	}

	/**
	 * Validate the space configuration and return the corresponding details.
	 * 
	 * @param parameters
	 *            the space parameters.
	 * @return Space's details.
	 */
	protected Space validateSpace(final Map<String, String> parameters) throws IOException {
		final String baseUrl = StringUtils.removeEnd(parameters.get(PARAMETER_URL), "/");

		CurlRequest[] requests = null;

		try {
			// Validate the space key and get activity
			requests = validateSpaceInternal(parameters, "/rest/api/space/",
					"/plugins/recently-updated/changes.action?theme=social&pageSize=1&spaceKeys=");

			// Parse the space details
			final Map<String, Object> details = objectMapper.readValue(requests[0].getResponse(), TYPE_SPACE_REF);

			// Build the full space object
			return toSpace(baseUrl, details, requests[1].getResponse(), requests[0].getProcessor());
		} finally {
			// Close the processor
			closeQuietly(requests);
		}
	}

	/**
	 * CLose the related processor as needed.
	 */
	private void closeQuietly(final CurlRequest[] requests) {
		if (requests != null) {
			requests[0].getProcessor().close();
		}

	}

	/**
	 * Validate the space configuration and return the corresponding details.
	 */
	protected CurlRequest[] validateSpaceInternal(final Map<String, String> parameters, final String... partialRequests) {
		final String url = StringUtils.removeEnd(parameters.get(PARAMETER_URL), "/");
		final String space = ObjectUtils.defaultIfNull(parameters.get(PARAMETER_SPACE), "0");
		final CurlRequest[] result = new CurlRequest[partialRequests.length];
		for (int i = 0; i < partialRequests.length; i++) {
			result[i] = new CurlRequest(HttpMethod.GET, url + partialRequests[i] + space, null);
			result[i].setSaveResponse(true);
		}

		// Prepare the sequence of HTTP requests to Confluence
		final ConfluenceCurlProcessor processor = new ConfluenceCurlProcessor();
		authenticate(parameters, processor);

		// Execute the requests
		processor.process(result);

		// Get the space if it exists
		if (result[0].getResponse() == null) {
			// Invalid couple PKEY and id
			throw new ValidationJsonException(PARAMETER_SPACE, "confluence-space", parameters.get(PARAMETER_SPACE));
		}
		return result;
	}

	@Override
	public void link(final int subscription) throws Exception {
		final Map<String, String> parameters = subscriptionResource.getParameters(subscription);

		// Validate the space key
		CurlRequest[] requests = null;
		try {
			requests = validateSpaceInternal(parameters, "/rest/api/space/");
		} finally {
			// Close the processor
			closeQuietly(requests);
		}
	}

	/**
	 * Find the spaces matching to the given criteria. Look into space key, and
	 * space name.
	 * 
	 * @param node
	 *            the node to be tested with given parameters.
	 * @param criteria
	 *            the search criteria.
	 * @return Matching spaces, ordered by space name, not the the key.
	 */
	@GET
	@Path("{node}/{criteria}")
	@Consumes(MediaType.APPLICATION_JSON)
	public List<Space> findAllByName(@PathParam("node") final String node, @PathParam("criteria") final String criteria)
			throws IOException {
		// Check the node exists
		if (nodeRepository.findOneVisible(node, securityHelper.getLogin()) == null) {
			return Collections.emptyList();
		}

		// Get the target node parameters
		final Map<String, String> parameters = pvResource.getNodeParameters(node);
		final List<Space> result = new ArrayList<>();
		int start = 0;
		// Limit the result to 10, and search with a page size of 100
		while (addAllByName(parameters, criteria, result, start) && result.size() < 10) {
			start += 100;
		}

		return inMemoryPagination.newPage(result, PageRequest.of(0, 10)).getContent();
	}

	/**
	 * Find the spaces matching to the given criteria. Look into space key, and
	 * space name.
	 * 
	 * @param parameters
	 *            the node parameters.
	 * @param criteria
	 *            the search criteria.
	 * @param start
	 *            the cursor position.
	 * @return <code>true</code> when there are more spaces to fetch.
	 */
	private boolean addAllByName(final Map<String, String> parameters, final String criteria, final List<Space> result, final int start)
			throws IOException {
		// The result should be JSON, otherwise, an empty result is mocked
		final String spacesAsJson = StringUtils.defaultString(
				getConfluenceResource(parameters, "/rest/api/space?type=global&limit=100&start=" + start),
				"{\"results\":[],\"_links\":{}}");

		// Build the result from JSON
		final TypeReference<Map<String, Object>> typeReference = new TypeReference<Map<String, Object>>() {
			// Nothing to override
		};
		final Map<String, Object> readValue = objectMapper.readValue(spacesAsJson, typeReference);
		@SuppressWarnings("unchecked")
		final Collection<Map<String, Object>> spaces = (Collection<Map<String, Object>>) readValue.get("results");

		// Prepare the context, an ordered set of projects
		final Format format = new NormalizeFormat();
		final String formatCriteria = format.format(criteria);

		// Get the projects and parse them
		for (final Map<String, Object> spaceRaw : spaces) {
			final Space space = toSpaceLight(spaceRaw);

			// Check the values of this project
			if (format.format(space.getName()).contains(formatCriteria) || format.format(space.getId()).contains(formatCriteria)) {
				result.add(space);
			}
		}
		return ((Map<?, ?>) readValue.get("_links")).containsKey("next");
	}

	/**
	 * Map raw Confluence values to a simple details of space
	 */
	private Space toSpaceLight(final Map<String, Object> spaceRaw) {
		final Space space = new Space();
		space.setId((String) spaceRaw.get("key"));
		space.setName((String) spaceRaw.get("name"));
		return space;
	}

	/**
	 * Map API JSON Space and history values to a bean.
	 */
	private Space toSpace(final String baseUrl, final Map<String, Object> spaceRaw, final String history, final CurlProcessor processor)
			throws MalformedURLException {
		final Space space = toSpaceLight(spaceRaw);
		final String hostUrl = StringUtils.removeEnd(baseUrl, new java.net.URL(baseUrl).getPath());

		// Check the activity if available
		final Matcher matcher = ACTIVITY_PATTERN.matcher(StringUtils.defaultString(history));
		if (matcher.find()) {
			// Activity has been found
			final SpaceActivity activity = new SpaceActivity();
			getAvatar(processor, activity, hostUrl + matcher.group(1));
			activity.setAuthor(toSimpleUser(matcher.group(2), matcher.group(3)));
			activity.setPageUrl(hostUrl + matcher.group(4));
			activity.setPage(matcher.group(5));
			activity.setMoment(matcher.group(6));
			space.setActivity(activity);
		}
		return space;
	}

	/**
	 * Return the avatar PNG file from URL.
	 */
	private void getAvatar(final CurlProcessor processor, final SpaceActivity activity, final String avatarUrl) {
		if (!avatarUrl.endsWith("/default.png")) {
			// Not default URL, get the PNG bytes
			processor.process(new CurlRequest("GET", avatarUrl, null, (req, res) -> {
				// PNG to DATA URL
				if (res.getStatusLine().getStatusCode() == HttpServletResponse.SC_OK) {
					activity.setAuthorAvatar("data:image/png;base64,"
							+ DatatypeConverter.printBase64Binary(IOUtils.toByteArray(res.getEntity().getContent())));
				}
				return true;
			}));
		}
	}

	/**
	 * Search the given user name using IAM, and if not found use the resolved
	 * Confluence display name.
	 * 
	 * @param login
	 *            The user login, as requested to IAM.
	 * @param displayName
	 *            The resolved Confluence display name, used when the user has
	 *            not been found in IAM.
	 * @return A {@link SimpleUser} instance representing at best effort the
	 *         requested user.
	 */
	protected SimpleUser toSimpleUser(final String login, final String displayName) {
		return Optional.ofNullable(getUser(login)).map(u -> {
			final SimpleUser user = new SimpleUser();
			u.copy(user);
			return user;
		}).orElseGet(() -> {
			final SimpleUser user = new SimpleUser();
			// Painful trying to separate first/last name
			user.setId(login);
			user.setFirstName(displayName);
			return user;
		});
	}

	/**
	 * Request IAM provider to get user details.
	 * 
	 * @param login
	 *            The requested user login.
	 * @return Either the resolved instance, either <code>null</code> when not
	 *         found.
	 */
	protected SimpleUser getUser(final String login) {
		return iamProvider[0].getConfiguration().getUserRepository().findById(login);
	}

	/**
	 * Return a Confluence's resource. Return <code>null</code> when the
	 * resource is not found.
	 */
	protected String getConfluencePublicResource(final Map<String, String> parameters, final String resource) {
		return getConfluenceResource(new CurlProcessor(), parameters.get(PARAMETER_URL), resource);
	}

	/**
	 * Return a Confluence's resource after an authentication. Return
	 * <code>null</code> when the resource is not found.
	 */
	protected String getConfluenceResource(final Map<String, String> parameters, final String resource) {
		final ConfluenceCurlProcessor processor = new ConfluenceCurlProcessor();
		authenticate(parameters, processor);
		return getConfluenceResource(processor, parameters.get(PARAMETER_URL), resource);
	}

	/**
	 * Return a Jenkins's resource. Return <code>null</code> when the resource
	 * is not found.
	 */
	protected String getConfluenceResource(final CurlProcessor processor, final String url, final String resource) {
		// Get the resource using the preempted authentication
		final CurlRequest request = new CurlRequest(HttpMethod.GET, StringUtils.removeEnd(url, "/") + resource, null);
		request.setSaveResponse(true);

		// Execute the requests
		processor.process(request);
		processor.close();
		return request.getResponse();
	}

	@Override
	public String getKey() {
		return ConfluencePluginResource.KEY;
	}

	@Override
	public String getVersion(final Map<String, String> parameters) throws Exception {
		final String page = StringUtils.trimToEmpty(getConfluencePublicResource(parameters, "/forgotuserpassword.action"));
		final String ajsMeta = "ajs-version-number\" content=";
		final int versionIndex = Math.min(page.length(), page.indexOf(ajsMeta) + ajsMeta.length() + 1);
		return StringUtils.trimToNull(page.substring(versionIndex, Math.max(versionIndex, page.indexOf('\"', versionIndex))));
	}

	@Override
	public String getLastVersion() throws Exception {
		// Get the download json from the default repository
		return versionUtils.getLatestReleasedVersionName("https://jira.atlassian.com", "CONF");
	}

	@Override
	public boolean checkStatus(final Map<String, String> parameters) throws Exception {
		// Status is UP <=> Administration access is UP (if defined)
		validateAccess(parameters);

		final CurlProcessor processor = new ConfluenceCurlProcessor();
		try {
			// Check the user can log-in to Confluence
			authenticate(parameters, processor);

			// Check the user has enough rights to access to the plugin page
			validateAdminAccess(parameters, processor);
		} finally {
			processor.close();
		}
		return true;
	}

	@Override
	public SubscriptionStatusWithData checkSubscriptionStatus(final Map<String, String> parameters) throws Exception {
		final SubscriptionStatusWithData data = new SubscriptionStatusWithData();
		data.put("space", validateSpace(parameters));
		return data;
	}
}