AccessionController.java

/*
 * Copyright 2019 Global Crop Diversity Trust
 *
 * Licensed 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.genesys.server.api.v1;

import java.io.BufferedWriter;
import java.io.EOFException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.genesys.blocks.model.JsonViews;
import org.genesys.blocks.security.serialization.CurrentPermissionsWriter;
import org.genesys.server.api.ApiBaseController;
import org.genesys.server.api.FilteredPage;
import org.genesys.server.api.FilteredSlice;
import org.genesys.server.api.Pagination;
import org.genesys.server.api.ScrollPagination;
import org.genesys.server.api.model.AccessionHeaderJson;
import org.genesys.server.component.aspect.DownloadEndpoint;
import org.genesys.server.exception.ClientDisconnectedException;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.model.genesys.Accession;
import org.genesys.server.model.genesys.AccessionId;
import org.genesys.server.model.genesys.QAccession;
import org.genesys.server.model.genesys.Taxonomy2;
import org.genesys.server.model.impl.AccessionIdentifier3;
import org.genesys.server.service.AccessionService;
import org.genesys.server.service.AccessionService.AccessionMapInfo;
import org.genesys.server.service.AccessionService.AccessionOverview;
import org.genesys.server.service.AccessionService.AccessionSuggestionPage;
import org.genesys.server.service.AccessionService.AccessionSuggestionSlice;
import org.genesys.server.service.AccessionService.LabelValue;
import org.genesys.server.service.AmphibianService;
import org.genesys.server.service.DownloadService;
import org.genesys.server.service.ElasticsearchService;
import org.genesys.server.service.ElasticsearchService.TermResult;
import org.genesys.server.service.GenesysService;
import org.genesys.server.service.ShortFilterService.FilterInfo;
import org.genesys.server.service.filter.AccessionFilter;
import org.genesys.server.service.filter.AccessionGeoFilter;
import org.genesys.server.exception.SearchException;
import org.genesys.server.service.worker.AccessionProcessor;
import org.genesys.server.service.worker.ShortFilterProcessor;
import org.genesys.server.service.worker.dupe.AccessionDuplicateFinder;
import org.genesys.server.service.worker.dupe.DuplicateFinder.Hit;
import org.genesys.server.service.worker.dupe.DuplicateFinder.SimilarityHit;
import org.genesys.spring.CSVMessageConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springdoc.api.annotations.ParameterObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.HandlerMapping;

import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Sets;
import com.opencsv.CSVWriter;

import io.swagger.annotations.Api;

/**
 * Accession API v1
 */
@RestController("accessionApi1")
@PreAuthorize("isAuthenticated()")
@RequestMapping(AccessionController.CONTROLLER_URL)
@Api(tags = { "accession" })
public class AccessionController extends ApiBaseController {

	private static final Logger LOG = LoggerFactory.getLogger(AccessionController.class);

	/** The Constant CONTROLLER_URL. */
	public static final String CONTROLLER_URL = ApiBaseController.APIv1_BASE + "/acn";

	private static final int DOWNLOAD_LIMIT = 300000;

	@Autowired
	private AccessionService accessionService;

	@Autowired
	protected ShortFilterProcessor shortFilterProcessor;

	@Autowired(required = false)
	private ElasticsearchService elasticsearchService;

	@Autowired
	private DownloadService downloadService;

	@Autowired
	private GenesysService genesysService;
	
	@Autowired
	private AmphibianService amphibianService;

	@Autowired
	private AccessionProcessor accessionProcessor;

	@Autowired
	private ObjectMapper objectMapper;

	private final Cache<String, AccessionOverview> accessionOverviewCache = CacheBuilder.newBuilder().maximumSize(50).expireAfterWrite(5, TimeUnit.MINUTES).build();
	private final Cache<String, AccessionMapInfo> accessionMapinfoCache = CacheBuilder.newBuilder().maximumSize(50).expireAfterWrite(5, TimeUnit.MINUTES).build();

	private final ObjectMapper mapper = new ObjectMapper();

	@Value("${frontend.url}")
	private String frontendUrl;

	@Value("${cdn.servers}")
	private String[] cdnServers;

	public static final Set<String> terms = Sets.newHashSet("institute.code", "institute.country.code3", "cropName", "crop.shortName", "taxonomy.genus", "taxonomy.species",
		"taxonomy.genusSpecies", "taxonomy.grinTaxonomySpecies.name", "taxonomy.currentTaxonomySpecies.name", "countryOfOrigin.code3", "sampStat", "available", "mlsStatus",
		"donorCode", "sgsv", "storage", "duplSite", "breederCode", "aegis", "curationType");

	@Autowired
	private AccessionDuplicateFinder duplicateFinder;

	@GetMapping(value = "/id/{id}", produces = { MediaType.APPLICATION_JSON_VALUE })
	public UUID uuidFromId(@PathVariable("id") final long id) {
		return accessionService.uuidFromId(id);
	}

	@PostMapping(value = "/id", produces = { MediaType.APPLICATION_JSON_VALUE })
	public List<UUID> uuidFromIds(@RequestBody List<Long> ids) {
		return accessionService.uuidsFromIds(ids);
	}


	@GetMapping(value = "/acce-number/{acceNumber}", produces = { MediaType.APPLICATION_JSON_VALUE })
	public UUID uuidFromAcceNumber(@RequestParam(value = "instCode", required = false) String instCode , @PathVariable("acceNumber") String acceNumber) {
		return accessionService.uuidFromAcceNumber(instCode, acceNumber);
	}

	@PostMapping(value = "/acce-number", produces = { MediaType.APPLICATION_JSON_VALUE })
	public List<UUID> uuidsFromAcceNumbers(@RequestParam(value = "instCode", required = false) String instCode, @RequestBody List<String> acceNumbers) {
		return accessionService.uuidsFromAcceNumbers(instCode, acceNumbers);
	}

	/**
	 * Gets the accession
	 *
	 * @param uuid the uuid
	 * @return the subset
	 */
	@GetMapping(value = "/{uuid:\\w{8}\\-\\w{4}.+}", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Protected.class })
	public Accession getByUuid(@PathVariable("uuid") final UUID uuid) {
		return accessionService.getByUuid(uuid);
	}

	/**
	 * Gets the accession
	 *
	 * @return the subset
	 */
	@GetMapping(value = "/10.{doi1:[0-9]+}/**", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Protected.class })
	public Accession getByDoi(final HttpServletRequest request) {
		final String doi = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).substring((CONTROLLER_URL + "/").length());
		return accessionService.getByDoi(doi);
	}

	/**
	 * List accessions by filterCode or filter
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws IOException
	 */
	@PostMapping(value = "/list", produces = { MediaType.APPLICATION_JSON_VALUE, CSVMessageConverter.TEXT_CSV_VALUE })
	@JsonView({ JsonViews.Public.class })
	public FilteredPage<Accession, AccessionFilter> list(@RequestParam(name = "f", required = false) String filterCode, @ParameterObject final Pagination page,
			@RequestBody(required = false) AccessionFilter filter) throws IOException, SearchException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		return new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.list(filterInfo.filter, page.toPageRequest(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, Sort.Direction.ASC, "seqNo")));
	}


	/**
	 * Query accession fields 
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @param select list of {@code Accession} fields to include in the response
	 * @param mcpd Concatenate arrays with ";". Default is <b>{@code false}</b>
	 * @return the page
	 * @throws Exception 
	 */
	@PostMapping(value = "/query", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Public.class })
	public FilteredPage<?, AccessionFilter> query(
			final Pagination page,
			@RequestParam(name = "f", required = false) String filterCode,
			@RequestBody(required = false) AccessionFilter filter,
			@RequestParam List<String> select,
			@RequestParam(required = false, defaultValue = "false") boolean mcpd,
			HttpServletRequest request
	) throws Exception {
		LOG.warn("Using MCPD style? {}", mcpd);
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
//		return new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.query(filterInfo.filter, select, page.toPageRequest(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE), mcpd));
		return new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.query(filterInfo.filter, select, page.toPageRequest(10000, DEFAULT_PAGE_SIZE), mcpd));
	}

	/**
	 * Query accession fields (for HTTP Form POST)
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @param select list of {@code Accession} fields to include in the response
	 * @param mcpd Concatenate arrays with ";". Default is <b>{@code true}</b>
	 * @throws Exception 
	 */
	@PostMapping(value = "/query", produces = { CSVMessageConverter.TEXT_CSV_VALUE, CSVMessageConverter.TEXT_TSV_VALUE }, consumes = { MediaType.APPLICATION_FORM_URLENCODED_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE })
	@JsonView({ JsonViews.Public.class })
	public void queryCsvForm(
			final Pagination page,
			@RequestParam(name = "f", required = false) String filterCode,
			@RequestParam String filter,
			@RequestParam List<String> select,
			@RequestParam(required = false, defaultValue = "true") boolean mcpd,
			HttpServletRequest request,
			HttpServletResponse response
	) throws Exception {
		queryCsv(page, filterCode, objectMapper.readValue(filter, AccessionFilter.class), select, mcpd, request, response);
	}
	/**
	 * Query accession fields. 
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @param select list of {@code Accession} fields to include in the response
	 * @param mcpd Concatenate arrays with ";". Default is <b>{@code true}</b>
	 * @throws Exception 
	 */
	@PostMapping(value = "/query", produces = { CSVMessageConverter.TEXT_CSV_VALUE, CSVMessageConverter.TEXT_TSV_VALUE })
	@JsonView({ JsonViews.Public.class })
	public void queryCsv(
			final Pagination page,
			@RequestParam(name = "f", required = false) String filterCode,
			@RequestBody(required = false) AccessionFilter filter,
			@RequestParam List<String> select,
			@RequestParam(required = false, defaultValue = "true") boolean mcpd,
			HttpServletRequest request,
			HttpServletResponse response
	) throws Exception {
		LOG.debug("Using MCPD style? {}", mcpd);
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		response.addHeader("Content-Type", CSVMessageConverter.TEXT_TSV_VALUE);
		String fileName = "query" + (StringUtils.isNotBlank(filterInfo.filterCode) ? "-".concat(filterInfo.filterCode) : "");
		response.addHeader("Content-Disposition", "attachment; filename=\"" + fileName + ".csv\"");
//		Does not work: response.getOutputStream().write('\uFEFF'); // UTF-8 BOM for Excel
		try (CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(response.getOutputStream(), "UTF8"), '\t', '"', '\\', "\n")) {
			var keysArr = new ArrayList<>();
			var row = new AtomicReference<String[]>();
			var counter = new AtomicInteger(0);

			accessionService.query(filterInfo.filter, select, page.toPageRequest(1000000, 10000, Sort.Direction.ASC, "seqNo"), mcpd, (one) -> {
				if (counter.getAndIncrement() == 0) {
					var row1 = (Map<?, ?>) one;
					var keys = row1.keySet();
					keysArr.addAll(keys);
					csvWriter.writeNext(row1.keySet().toArray(new String[keys.size()]), false);
					row.set(new String[keys.size()]);
				}
				var r = row.get();
				for (var i = 0; i < r.length; i++) {
					var val = one.get(keysArr.get(i));
					r[i] = val == null ? null : Objects.toString(val);
				}
				csvWriter.writeNext(r, false);
				try {
					if (counter.get() % 50 == 0) csvWriter.flush(); // Flush every 50 rows to check if the client is still connected
				} catch (IOException e) {
					throw new ClientDisconnectedException(e);
				}
			});
			csvWriter.flush();
		};
	}


	/**
	 * List distinct taxonomic data for filtered accessions
	 *
	 * @param filter the accession filter
	 * @throws IOException
	 */
	@PostMapping(value = "/species", produces = { MediaType.APPLICATION_JSON_VALUE, CSVMessageConverter.TEXT_CSV_VALUE })
	@JsonView({ JsonViews.Public.class })
	public List<Taxonomy2> listSpecies(@RequestBody(required = false) AccessionFilter filter) throws IOException, SearchException {

		return accessionService.listSpecies(filter);
	}

	static interface RootNoPermissions extends JsonViews.Root, CurrentPermissionsWriter.NoPermissions { }

	/**
	 * List accessions by filterCode or filter
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws IOException
	 * @throws SearchException
	 */
	@JsonView({ RootNoPermissions.class }) // same as getAccessionDetails so we get imageGallery!
	@PostMapping(value = "/images", params = { "p" }, produces = { MediaType.APPLICATION_JSON_VALUE })
	public AccessionSuggestionPage<AccessionService.AccessionDetails, AccessionFilter> images(@RequestParam(name = "f", required = false) final String filterCode, @ParameterObject final Pagination page,
			@RequestBody(required = false) final AccessionFilter filter) throws IOException, SearchException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

//		FilteredPage<AccessionService.AccessionDetails> pageRes = new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.withImages(filterInfo.filter, page.toPageRequest(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, Sort.Direction.ASC, "seqNo")));
		FilteredPage<AccessionService.AccessionDetails, AccessionFilter> pageRes = new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.withImages(filterInfo.filter, page.toPageRequest(20, 20, Sort.Direction.ASC, "seqNo")));

		filterInfo.filter.images(true);
		Map<String, TermResult> suggestionRes = accessionService.getSuggestions(filterInfo.filter);

		return new AccessionSuggestionPage<>(pageRes, suggestionRes);
	}

	@JsonView({ RootNoPermissions.class }) // same as getAccessionDetails so we get imageGallery!
	@PostMapping(value = "/images", params = { "o" }, produces = { MediaType.APPLICATION_JSON_VALUE })
	public AccessionSuggestionSlice<AccessionService.AccessionDetails> images(@RequestParam(name = "f", required = false) final String filterCode,
			final ScrollPagination page, @RequestBody(required = false) final AccessionFilter filter) throws IOException, SearchException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		FilteredSlice<AccessionService.AccessionDetails> pageRes = new FilteredSlice<>(filterInfo.filterCode, filterInfo.filter, accessionService.withImagesSlice(filterInfo.filter, page.toPageRequest(20, Sort.Direction.ASC, "seqNo")));

		filterInfo.filter.images(true);
		Map<String, TermResult> suggestionRes = accessionService.getSuggestions(filterInfo.filter);

		return new AccessionSuggestionSlice<>(pageRes, suggestionRes);
	}

	@PostMapping(value = "/images/count", produces = { MediaType.APPLICATION_JSON_VALUE })
	public int countImages(@RequestParam(name = "f", required = false) final String filterCode, 
		@RequestBody(required = false) final AccessionFilter filter) throws SearchException, Exception {
		
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		return accessionService.countAccessionsImages(filterInfo.filter);
	}

	@PreAuthorize("hasRole('USER')")
	@DownloadEndpoint
	@RequestMapping(value = "/downloadImages", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE, method = RequestMethod.POST)
	public void downloadImages(@RequestParam(value = "f", required = false, defaultValue = "") String filterCode, HttpServletResponse response) throws Exception {

		// get AccessionFilter from filterCode
		AccessionFilter filter = shortFilterProcessor.filterByCode(filterCode, AccessionFilter.class);
		filter.images(true);

		final long countFiltered = accessionService.countAccessions(filter);
		// LOG.info("Attempting to download images for {} accessions", countFiltered);
		if (countFiltered > 100) {
			throw new InvalidApiUsageException("Refusing to export more than " + 100 + " entries");
		}

		// Write Zip file archive to the stream.
		response.setBufferSize(4*1024);
		response.setContentType("application/zip");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-images-%1$s.zip\"", filterCode));

		try (final OutputStream outputStream = response.getOutputStream()) {
			try {
				downloadService.writeAccessionImageArchive(filter, outputStream);
				outputStream.flush();
				response.flushBuffer();
			} catch (Throwable e) {
				outputStream.write(e.getMessage().getBytes());
				throw e;
			}
		}
	}

	/**
	 * List accessions by filterCode or filter
	 *
	 * @param page the page
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws IOException
	 */
	@PostMapping(value = "/filter", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Public.class })
	public AccessionSuggestionPage<Accession, AccessionFilter> filter(@RequestParam(name = "f", required = false) String filterCode, @ParameterObject final Pagination page,
			@RequestBody(required = false) AccessionFilter filter) throws IOException, SearchException {

		LOG.debug("Received filter: {}", filter);
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		LOG.debug("Processed filter: {}", filterInfo.filter);
		var pageRes = new FilteredPage<>(filterInfo.filterCode, filterInfo.filter, accessionService.list(filterInfo.filter, page.toPageRequest(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, Sort.Direction.ASC, "seqNo")));
		Map<String, TermResult> suggestionRes = accessionService.getSuggestions(filterInfo.filter);

		return new AccessionSuggestionPage<>(pageRes, suggestionRes);
	}

	/**
	 * Get term overview for filters
	 *
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws SearchException
	 */
	@PostMapping(value = "/overview", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Public.class })
	public AccessionOverview overview(@RequestParam(name = "f", required = false) final String filterCode, @RequestBody(required = false) final AccessionFilter filter,
			@RequestParam(name = "limit", defaultValue = "10", required = false) final int limit) throws IOException, SearchException, ExecutionException, InterruptedException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		return accessionOverviewCache.get(filterInfo.filterCode + ",limit=" + limit, () -> {
			var stopWatch = StopWatch.createStarted();
			Map<String, TermResult> overview = elasticsearchService.termStatisticsAuto(Accession.class, filterInfo.filter, Math.min(50, limit), terms.toArray(new String[] {}));
			LOG.info("overview termStatisticsAuto {}ms", stopWatch.getTime());
	
			TermResult result = elasticsearchService.recountResult(Accession.class, QAccession.accession.accessionId().storage, filterInfo.filter, overview.get("storage"), "storage");
			LOG.info("overview recountResult {}ms", stopWatch.getTime());
			overview.put("storage", result);

			long accessionCount = accessionService.countAccessions(filterInfo.filter);
			LOG.info("overview countAccessions {}ms", stopWatch.getTime());
			Map<String, TermResult> suggestions = accessionService.getSuggestions(filterInfo.filter);
			LOG.info("overview getSuggestions {}ms", stopWatch.getTime());

			return AccessionOverview.from(filterInfo.filterCode, filterInfo.filter, overview, accessionCount, suggestions);
		});
	}

	/**
	 * Get overview tree for filters and provided terms
	 *
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @param terms the terms (order is important!)
	 * @return the overview tree
	 *
	 * @throws SearchException the search exception
	 * @throws IOException the IO exception
	 */
	@PostMapping(value = "/overview-tree", produces = { MediaType.APPLICATION_JSON_VALUE })
	public ElasticsearchService.TreeNode overviewTree(@RequestParam(name = "f", required = false) final String filterCode,
			@RequestBody(required = false) final AccessionFilter filter,
			@RequestParam(name = "terms", required = true) final String[] terms) throws SearchException, IOException {

		if (ArrayUtils.isEmpty(terms)) {
			throw new InvalidApiUsageException("Terms must be provided!");
		}

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		var rootNode = elasticsearchService.treeNodeStatistics(Accession.class, filterInfo.filter, terms);
		rootNode.filterCode = filterInfo.filterCode;
		return rootNode;
	}

	/**
	 * Load more data for the specified term
	 *
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @param term the term
	 * @return the term result
	 * @throws SearchException the search exception
	 * @throws IOException signals that an I/O exception has occurred
	 */
	@PostMapping(value = "/overview/{term}", produces = { MediaType.APPLICATION_JSON_VALUE })
	public TermResult loadMoreTerms(@PathVariable(name = "term") final String term, @RequestBody(required = false) final AccessionFilter filter,
			@RequestParam(name = "f", required = false) final String filterCode, @RequestParam(name = "limit", defaultValue = "20", required = false) final int limit)
			throws IOException, SearchException, ExecutionException, InterruptedException {

		if (! terms.contains(term)) {
			throw new InvalidApiUsageException("No such term. Will not search.");
		}

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		TermResult termResult = elasticsearchService.termStatisticsAuto(Accession.class, filterInfo.filter, Math.min(200, limit), term);
		if (term.equals("storage")) {
			termResult = elasticsearchService.recountResult(Accession.class, QAccession.accession.accessionId().storage, filterInfo.filter, termResult, "storage");
		}
		return termResult;
	}

	/**
	 * Gets accessions by list of uuid-s
	 *
	 * @param uuids accession identifi`ers to lookup in DB
	 * @return list of Accessions
	 */
	@JsonView({ JsonViews.Public.class })
	@PostMapping(value = "/for-uuid", produces = { MediaType.APPLICATION_JSON_VALUE, CSVMessageConverter.TEXT_CSV_VALUE })
	public List<Accession> forUuids(@RequestBody Set<UUID> uuids) {
		return accessionService.forUuids(uuids);
	}

	/**
	 * Converts AccessionIdentifiers to UUID
	 *
	 * @param identifiers accession identifiers to lookup in DB
	 * @return map with UUIDs and related AccessionIdentifiers
	 */
	@PostMapping(value = "/toUUID", produces = { MediaType.APPLICATION_JSON_VALUE })
	public Map<UUID, AccessionIdentifier3> toUUID(@RequestBody List<AccessionHeaderJson> identifiers) {
		return accessionService.toUUID(identifiers);
	}

	@GetMapping(value = "/details/10.{doi1:[0-9]+}/**", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Root.class)
	public AccessionService.AccessionDetails getAccessionDetailsByDoi(final HttpServletRequest request) {
		final String doi = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).substring((CONTROLLER_URL + "/details/").length());

		Accession accession = accessionService.getByDoi(doi);
		return accessionService.getAccessionDetails(accession);
	}

	@GetMapping(value = "/details/{uuid:\\w{8}\\-\\w{4}.+}", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Root.class)
	public AccessionService.AccessionDetails getAccessionDetailsByUUID(@PathVariable("uuid") final UUID uuid) {
		Accession accession = accessionService.getByUuid(uuid);
		return accessionService.getAccessionDetails(accession);
	}

	@PreAuthorize("isAuthenticated()")
	@GetMapping(value = "/similar/{uuid:\\w{8}\\-\\w{4}.+}", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Public.class)
	public List<Hit<Accession>> getSimilarAccessionsForUUID(@PathVariable("uuid") final UUID uuid) {
		Accession accession = accessionService.getByUuid(uuid);
		return duplicateFinder.findSimilar(accession, null);
	}

	@PreAuthorize("isAuthenticated()")
	@PostMapping(value = "/similar/{uuid:\\w{8}\\-\\w{4}.+}", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Public.class)
	public List<Hit<Accession>> getSimilarAccessionsForUUID(@PathVariable("uuid") final UUID uuid, @RequestBody(required = false) AccessionFilter filter) {
		Accession accession = accessionService.getByUuid(uuid);
		return duplicateFinder.findSimilar(accession, filter);
	}

	@PreAuthorize("isAuthenticated()")
	@GetMapping(value = "/auditlog/10.{doi1:[0-9]+}/**", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Root.class)
	public AccessionService.AccessionAuditLog getAccessionAuditLogByDoi(final HttpServletRequest request) {
		final String doi = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).substring((CONTROLLER_URL + "/auditlog/").length());
		Accession accession = accessionService.getByDoi(doi);
		return accessionService.getAuditLog(accession);
	}

	@PreAuthorize("isAuthenticated()")
	@GetMapping(value = "/auditlog/{uuid:\\w{8}\\-\\w{4}.+}", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView(JsonViews.Root.class)
	public AccessionService.AccessionAuditLog getAccessionAuditLogByUUID(@PathVariable("uuid") final UUID uuid) {
		Accession accession = accessionService.getByUuid(uuid);
		return accessionService.getAuditLog(accession);
	}

	@PostMapping(value = "/mapinfo", produces = MediaType.APPLICATION_JSON_VALUE)
	@JsonView({ JsonViews.Public.class })
	public AccessionMapInfo mapInfo(@RequestParam(value = "f", required = false) String filterCode, @RequestBody(required = false) AccessionFilter filter) throws IOException, SearchException, ExecutionException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		return accessionMapinfoCache.get(filterInfo.filterCode, () -> {

			// Force only georeferenced accessions
			AccessionFilter georefFilter = filterInfo.filter.copy(AccessionFilter.class);
			georefFilter.geo().referenced(true);
	
			AccessionMapInfo mapInfo = new AccessionMapInfo();
			mapInfo.filterCode = filterInfo.filterCode;
			mapInfo.filter = filterInfo.filter;
	
			if (StringUtils.isBlank(filterInfo.filterCode)) {
				// Entire map
				mapInfo.bounds = AccessionService.DEFAULT_GEOBOUNDS;
			} else {
				mapInfo.bounds = accessionService.getGeoBounds(georefFilter);
			}
			mapInfo.accessionCount = accessionService.countAccessions(georefFilter);
			mapInfo.tileServers = cdnServers;
			mapInfo.suggestions= accessionService.getSuggestions(filterInfo.filter);
	
			return mapInfo;
		});
	}

	/**
	 * Returns accession json by filter
	 *
	 * @param limit - max count of accession returned
	 * @param filter - filter
	 * @return json with minimal accession data
	 */
	@PostMapping(value = "/geoJson", produces = MediaType.APPLICATION_JSON_VALUE)
	public ObjectNode geoJson(@RequestParam(value = "limit", required = false, defaultValue = "100") Long limit,
			@RequestBody AccessionFilter filter) throws Exception {

		final ObjectNode geoJson = mapper.createObjectNode();
		final ArrayNode featuresArray = geoJson.arrayNode();

		accessionProcessor.process(filter, (accessions) -> {
			for (Accession accession: accessions) {
				final ObjectNode feature = featuresArray.objectNode();
				feature.put("type", "Feature");
				feature.put("id", accession.getId());
	
				ObjectNode geometry;
				feature.set("geometry", geometry = feature.objectNode());
				geometry.put("type", "Point");
	
				ArrayNode coordArray;
				geometry.set("coordinates", coordArray = geometry.arrayNode());
				coordArray.add(accession.getAccessionId().getLongitude());
				coordArray.add(accession.getAccessionId().getLatitude());
	
				ObjectNode properties;
				feature.set("properties", properties = feature.objectNode());
				properties.put("uuid", accession.getAccessionId().getUuid().toString());
				properties.put("doi", accession.getDoi());
				properties.put("accessionNumber", accession.getAccessionNumber());
				properties.put("instCode", accession.getInstCode());
				properties.put("datum", accession.getAccessionId().getCoordinateDatum());
				properties.put("uncertainty",  accession.getAccessionId().getCoordinateUncertainty());
	
				featuresArray.add(feature);
			}
		}, limit);

		geoJson.set("geoJson", featuresArray);

		long accessionCount = accessionService.countAccessions(filter);
		if (accessionCount > limit) {
			geoJson.put("otherCount", accessionCount - limit);
		}

		return geoJson;
	}

	@GetMapping(value = "/autocomplete/{field:.+}", produces = MediaType.APPLICATION_JSON_VALUE)
	public List<LabelValue<String>> autocomplete(@PathVariable("field") String field,
			@RequestParam(value = "term", required = true) String term,
			@RequestParam(name = "f", required = false) String filterCode, @RequestBody(required = false) AccessionFilter filter) throws IOException {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		return accessionService.autocomplete(filterInfo.filter, field, term);
	}

	@DownloadEndpoint
	@RequestMapping(value = "/downloadKml", produces = "application/vnd.google-earth.kml+xml", method = RequestMethod.POST)
	public void downloadKml(@RequestParam(value = "f", required = false, defaultValue = "") String filterCode, @RequestParam(value="filter", required = false) AccessionFilter filter, HttpServletResponse response) throws Exception {

		// get AccessionFilter from filterCode
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		AccessionGeoFilter geoFilter = filterInfo.filter.geo;
		if (geoFilter == null) {
			filterInfo.filter.geo = geoFilter = new AccessionGeoFilter();
		}
		geoFilter.referenced(true);

		final long countFiltered = accessionService.countAccessions(filterInfo.filter);
		// LOG.info("Attempting to download KML for {} accessions", countFiltered);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		response.setContentType("application/vnd.google-earth.kml+xml");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-kml-%1s.kml\"", filterInfo.filterCode));

		DecimalFormatSymbols dfs = new DecimalFormatSymbols();
		dfs.setDecimalSeparator('.');
		DecimalFormat decimalFormat = new DecimalFormat("0.#", dfs);
		decimalFormat.setMinimumIntegerDigits(1);
		decimalFormat.setMinimumFractionDigits(6);
		decimalFormat.setGroupingUsed(false);

		// Write KML to the stream.
		final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response.getOutputStream()));
		writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
		writer.write("<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n");
		writer.write("<Document>\n");
		try {
			accessionProcessor.process(filterInfo.filter, (accessions) -> {
				for (Accession accession: accessions) {
					AccessionId aid = accession.getAccessionId();
					if (aid != null && aid.getLongitude() != null && aid.getLatitude() != null) {
						writer.append("<Placemark>");
						writer.append("<name>").append(accession.getAccessionNumber()).append("</name>");
	
						writer.append("<description><![CDATA[\n");
						writer.append("<p>").append(accession.getTaxonomy().getTaxonNameHtml()).append("</p>");
						writer.append("<p>").append(accession.getInstitute().getCode()).append(" ").append(accession.getInstitute().getFullName()).append("</p>");
						writer.append("<p><a href=\"").append(frontendUrl).append("/a/").append(accession.getUuid().toString()).append("\">Passport data</a></p>");
						writer.append("\n]]></description>");
	
						writer.append("<Point><coordinates>");
						writer.append(decimalFormat.format(aid.getLongitude())).append(",").append(decimalFormat.format(aid.getLatitude()));
						writer.append("</coordinates></Point>");
						writer.append("</Placemark>\n");
						writer.flush();
					}
				}
			});
			writer.write("</Document>\n</kml>\n");

		} catch (EOFException e) {
			LOG.warn("Download was aborted: {}", e.getMessage());
			throw e;
		} catch (Exception e) {
			LOG.warn("Error generating KML: {}", e.getMessage());
			throw e;
		} finally {
			writer.flush();
			writer.close();
			response.flushBuffer();
		}
	}

	@DownloadEndpoint
	@RequestMapping(value = "/download-selected", method = RequestMethod.POST, params = { "mcpd" })
	public void downloadMcpdByUuids(@RequestParam(value="uuids", required = true) Set<UUID> uuids, HttpServletResponse response) throws IOException, SearchException {
		// Create JSON filter
		AccessionFilter filter = new AccessionFilter();
		filter.historic(null);
		filter.uuid(new HashSet<>(uuids));

		final long countFiltered = accessionService.countAccessions(filter);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		// Write MCPD to the stream.
		response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-accessions-selected-%1s.xlsx\"", System.currentTimeMillis()));

		final OutputStream outputStream = response.getOutputStream();
		try {
			downloadService.writeXlsxMCPD(filter, outputStream, null, "/sel");
			response.flushBuffer();
		} catch (EOFException e) {
			LOG.warn("Download was aborted: {}", e.getMessage());
			throw e;
		}
	}

	@DownloadEndpoint
	@RequestMapping(value = "/download-selected", method = RequestMethod.POST, params = { "dwca" })
	public void downloadDwcaByUuids(@RequestParam(value="uuids", required = true) Set<UUID> uuids, HttpServletResponse response) throws Exception {
		// Create JSON filter
		AccessionFilter filter = new AccessionFilter();
		filter
			.historic(null)
			.uuid(new HashSet<>(uuids));

		final long countFiltered = accessionService.countAccessions(filter);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		// Write Darwin Core Archive to the stream.
		response.setContentType("application/zip");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-accessions-selected-%1$s.zip\"", System.currentTimeMillis()));

		final OutputStream outputStream = response.getOutputStream();
		genesysService.writeAccessions(filter, outputStream, null, "/sel");
		response.flushBuffer();
	}

	@DownloadEndpoint
	@RequestMapping(value = "/download", method = RequestMethod.POST, params = { "mcpd" })
	public void downloadMcpd(@RequestParam(value = "f", required = false, defaultValue = "") String filterCode, @RequestParam(value="filter", required = false) AccessionFilter filter, HttpServletResponse response) throws IOException, SearchException {

		// get AccessionFilter from filterCode
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		final long countFiltered = accessionService.countAccessions(filterInfo.filter);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		// Write MCPD to the stream.
		response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-accessions-%1s.xlsx\"", filterInfo.filterCode));
		// response.flushBuffer();

		final OutputStream outputStream = response.getOutputStream();
		try {
			downloadService.writeXlsxMCPD(filterInfo.filter, outputStream, filterInfo.filterCode, "/a/" + filterInfo.filterCode);
			response.flushBuffer();
		} catch (EOFException e) {
			LOG.warn("Download was aborted: {}", e.getMessage());
			throw e;
		}
	}


	@DownloadEndpoint
	@RequestMapping(value = "/download", method = RequestMethod.POST, params = { "pdci" })
	public void downloadPdci(@RequestParam(value = "f", required = false, defaultValue = "") String filterCode, @RequestParam(value="filter", required = false) AccessionFilter filter, HttpServletResponse response) throws IOException, SearchException {

		// get AccessionFilter from filterCode
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		final long countFiltered = accessionService.countAccessions(filterInfo.filter);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		// Write PDCI to the stream.
		response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-PDCI-%1s.xlsx\"", filterInfo.filterCode));
		// response.flushBuffer();

		final OutputStream outputStream = response.getOutputStream();
		try {
			downloadService.writeXlsxPDCI(filterInfo.filter, outputStream, filterInfo.filterCode, "/a/" + filterInfo.filterCode);
			response.flushBuffer();
		} catch (EOFException e) {
			LOG.warn("Download was aborted: {}", e.getMessage());
			throw e;
		}
	}

	@DownloadEndpoint
	@RequestMapping(value = "/download", method = RequestMethod.POST, params = { "dwca" })
	public void downloadDwca(@RequestParam(value = "f", required = false, defaultValue = "") String filterCode, @RequestParam(value="filter", required = false) AccessionFilter filter, HttpServletResponse response) throws Exception {

		// get AccessionFilter from filterCode
		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);

		final long countFiltered = accessionService.countAccessions(filterInfo.filter);
		if (countFiltered > DOWNLOAD_LIMIT) {
			throw new InvalidApiUsageException("Refusing to export more than " + DOWNLOAD_LIMIT + " entries");
		}

		// Write Darwin Core Archive to the stream.
		response.setContentType("application/zip");
		response.addHeader("Content-Disposition", String.format("attachment; filename=\"genesys-accessions-%1$s.zip\"", filterInfo.filterCode));

		final OutputStream outputStream = response.getOutputStream();
		genesysService.writeAccessions(filterInfo.filter, outputStream, filterInfo.filterCode, "/a/" + filterInfo.filterCode);
		response.flushBuffer();
	}


	/**
	 * Returns accession json by filter
	 *
	 * @param params - similarity search params {@link SimilaritySearchParams}
	 * @return json with minimal accession data
	 */
	@PostMapping(value = "/find-similar", produces = MediaType.APPLICATION_JSON_VALUE)
	public List<SimilarityHit<Accession>> findSimilar(@RequestBody SimilaritySearchParams params) throws Exception {
		List<SimilarityHit<Accession>> results = new ArrayList<>();

		final long countFiltered = accessionService.countAccessions(params.select);
		
		if (countFiltered > 100) {
			throw new InvalidApiUsageException("Too many matches for similarity search!");
		}
		
		accessionProcessor.process(params.select, (accessions) -> {
			results.addAll(duplicateFinder.findSimilar(accessions, params.target));
		});

		return results;
	}

	@Accessors(fluent = true, chain = true)
	@Getter
	@Setter
	public static class SimilaritySearchParams {
		public AccessionFilter select; // Which accessions to process
		public AccessionFilter target; // What target filter to apply
	}

	/**
	 * Get term overview for filters
	 *
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws Exception 
	 */
	@PostMapping(value = "/tileIndex3min", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Public.class })
	public Set<Integer> allTileIndex3min(@RequestParam(name = "f", required = false) final String filterCode, @RequestBody(required = false) final AccessionFilter filter) throws Exception {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		return accessionService.listTileIndex3min(filterInfo.filter);
	}

	/**
	 * Get term overview for filters
	 *
	 * @param filterCode short filter code
	 * @param filter the filter
	 * @return the page
	 * @throws Exception 
	 */
	@PostMapping(value = "/tileIndex3min", produces = { MediaType.APPLICATION_JSON_VALUE }, params = { "crop" })
	@JsonView({ JsonViews.Public.class })
	public Map<String, Set<Integer>> allTileIndex3minByCrop(@RequestParam(name = "f", required = false) final String filterCode, @RequestBody(required = false) final AccessionFilter filter) throws Exception {

		FilterInfo<AccessionFilter> filterInfo = shortFilterProcessor.processFilter(filterCode, filter, AccessionFilter.class);
		return accessionService.listTileIndex3minByCrop(filterInfo.filter);
	}

	@GetMapping(value = "/{uuid:\\w{8}\\-\\w{4}.+}/observations", produces = MediaType.APPLICATION_JSON_VALUE)
	public List<?> getAccessionObservations(@PathVariable("uuid") final UUID uuid) throws Exception {
		var observations = amphibianService.getAccessionObservations(uuid);
		return new ArrayList<Object>(CollectionUtils.union(observations.firstPartyData, observations.thirdPartyData));
	}
}