package com.commafeed.frontend.resource;

import java.io.StringWriter;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;

import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.cache.CacheService;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Category;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.Subscription;
import com.commafeed.frontend.model.UnreadCount;
import com.commafeed.frontend.model.request.AddCategoryRequest;
import com.commafeed.frontend.model.request.CategoryModificationRequest;
import com.commafeed.frontend.model.request.CollapseRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.SyndFeedOutput;

import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Path("/category")
@Api(value = "/category")
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class CategoryREST {

	public static final String ALL = "all";
	public static final String STARRED = "starred";

	private final FeedCategoryDAO feedCategoryDAO;
	private final FeedEntryStatusDAO feedEntryStatusDAO;
	private final FeedSubscriptionDAO feedSubscriptionDAO;
	private final FeedEntryService feedEntryService;
	private final FeedSubscriptionService feedSubscriptionService;
	private final CacheService cache;
	private final CommaFeedConfiguration config;

	@Path("/entries")
	@GET
	@UnitOfWork
	@ApiOperation(value = "Get category entries", notes = "Get a list of category entries", response = Entries.class)
	@Timed
	public Response getCategoryEntries(@ApiParam(hidden = true) @SecurityCheck User user,
			@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
			@ApiParam(
					value = "all entries or only unread ones",
					allowableValues = "all,unread",
					required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
			@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
			@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
			@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
			@ApiParam(
					value = "ordering",
					allowableValues = "asc,desc,abc,zyx") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
			@ApiParam(
					value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
			@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
			@ApiParam(
					value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
			@ApiParam(value = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {

		Preconditions.checkNotNull(readType);

		keywords = StringUtils.trimToNull(keywords);
		Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3);
		List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);

		limit = Math.min(limit, 1000);
		limit = Math.max(0, limit);

		Entries entries = new Entries();
		entries.setOffset(offset);
		entries.setLimit(limit);
		boolean unreadOnly = readType == ReadingMode.unread;
		if (StringUtils.isBlank(id)) {
			id = ALL;
		}

		Date newerThanDate = newerThan == null ? null : new Date(newerThan);

		List<Long> excludedIds = null;
		if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
			excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).collect(Collectors.toList());
		}

		if (ALL.equals(id)) {
			entries.setName(Optional.ofNullable(tag).orElse("All"));
			List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
			removeExcludedSubscriptions(subs, excludedIds);
			List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
					offset, limit + 1, order, true, onlyIds, tag);

			for (FeedEntryStatus status : list) {
				entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
						config.getApplicationSettings().getImageProxyEnabled()));
			}

		} else if (STARRED.equals(id)) {
			entries.setName("Starred");
			List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, !onlyIds);
			for (FeedEntryStatus status : starred) {
				entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
						config.getApplicationSettings().getImageProxyEnabled()));
			}
		} else {
			FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
			if (parent != null) {
				List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
				List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
				removeExcludedSubscriptions(subs, excludedIds);
				List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
						offset, limit + 1, order, true, onlyIds, tag);

				for (FeedEntryStatus status : list) {
					entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
							config.getApplicationSettings().getImageProxyEnabled()));
				}
				entries.setName(parent.getName());
			} else {
				return Response.status(Status.NOT_FOUND).entity("<message>category not found</message>").build();
			}
		}

		boolean hasMore = entries.getEntries().size() > limit;
		if (hasMore) {
			entries.setHasMore(true);
			entries.getEntries().remove(entries.getEntries().size() - 1);
		}

		entries.setTimestamp(System.currentTimeMillis());
		entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null);
		FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords);
		return Response.ok(entries).build();
	}

	@Path("/entriesAsFeed")
	@GET
	@UnitOfWork
	@ApiOperation(value = "Get category entries as feed", notes = "Get a feed of category entries")
	@Produces(MediaType.APPLICATION_XML)
	@Timed
	public Response getCategoryEntriesAsFeed(@ApiParam(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
			@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
			@ApiParam(
					value = "all entries or only unread ones",
					allowableValues = "all,unread",
					required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
			@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
			@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
			@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
			@ApiParam(value = "date ordering", allowableValues = "asc,desc") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
			@ApiParam(
					value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
			@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
			@ApiParam(
					value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
			@ApiParam(value = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {

		Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds,
				excludedSubscriptionIds, tag);
		if (response.getStatus() != Status.OK.getStatusCode()) {
			return response;
		}
		Entries entries = (Entries) response.getEntity();

		SyndFeed feed = new SyndFeedImpl();
		feed.setFeedType("rss_2.0");
		feed.setTitle("CommaFeed - " + entries.getName());
		feed.setDescription("CommaFeed - " + entries.getName());
		feed.setLink(config.getApplicationSettings().getPublicUrl());
		feed.setEntries(entries.getEntries().stream().map(e -> e.asRss()).collect(Collectors.toList()));

		SyndFeedOutput output = new SyndFeedOutput();
		StringWriter writer = new StringWriter();
		try {
			output.output(feed, writer);
		} catch (Exception e) {
			writer.write("Could not get feed information");
			log.error(e.getMessage(), e);
		}
		return Response.ok(writer.toString()).build();
	}

	@Path("/mark")
	@POST
	@UnitOfWork
	@ApiOperation(value = "Mark category entries", notes = "Mark feed entries of this category as read")
	@Timed
	public Response markCategoryEntries(@ApiParam(hidden = true) @SecurityCheck User user,
			@ApiParam(value = "category id, or 'all'", required = true) MarkRequest req) {
		Preconditions.checkNotNull(req);
		Preconditions.checkNotNull(req.getId());

		Date olderThan = req.getOlderThan() == null ? null : new Date(req.getOlderThan());
		String keywords = req.getKeywords();
		List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);

		if (ALL.equals(req.getId())) {
			List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
			removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
			feedEntryService.markSubscriptionEntries(user, subs, olderThan, entryKeywords);
		} else if (STARRED.equals(req.getId())) {
			feedEntryService.markStarredEntries(user, olderThan);
		} else {
			FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(req.getId()));
			List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
			List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
			removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
			feedEntryService.markSubscriptionEntries(user, subs, olderThan, entryKeywords);
		}
		return Response.ok().build();
	}

	private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
		if (CollectionUtils.isNotEmpty(excludedIds)) {
			Iterator<FeedSubscription> it = subs.iterator();
			while (it.hasNext()) {
				FeedSubscription sub = it.next();
				if (excludedIds.contains(sub.getId())) {
					it.remove();
				}
			}
		}
	}

	@Path("/add")
	@POST
	@UnitOfWork
	@ApiOperation(value = "Add a category", notes = "Add a new feed category", response = Long.class)
	@Timed
	public Response addCategory(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) AddCategoryRequest req) {
		Preconditions.checkNotNull(req);
		Preconditions.checkNotNull(req.getName());

		FeedCategory cat = new FeedCategory();
		cat.setName(req.getName());
		cat.setUser(user);
		cat.setPosition(0);
		String parentId = req.getParentId();
		if (parentId != null && !ALL.equals(parentId)) {
			FeedCategory parent = new FeedCategory();
			parent.setId(Long.valueOf(parentId));
			cat.setParent(parent);
		}
		feedCategoryDAO.saveOrUpdate(cat);
		cache.invalidateUserRootCategory(user);
		return Response.ok(cat.getId()).build();
	}

	@POST
	@Path("/delete")
	@UnitOfWork
	@ApiOperation(value = "Delete a category", notes = "Delete an existing feed category")
	@Timed
	public Response deleteCategory(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) IDRequest req) {

		Preconditions.checkNotNull(req);
		Preconditions.checkNotNull(req.getId());

		FeedCategory cat = feedCategoryDAO.findById(user, req.getId());
		if (cat != null) {
			List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, cat);
			for (FeedSubscription sub : subs) {
				sub.setCategory(null);
			}
			feedSubscriptionDAO.saveOrUpdate(subs);
			List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
			for (FeedCategory child : categories) {
				if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) {
					child.setParent(null);
				}
			}
			feedCategoryDAO.saveOrUpdate(categories);

			feedCategoryDAO.delete(cat);
			cache.invalidateUserRootCategory(user);
			return Response.ok().build();
		} else {
			return Response.status(Status.NOT_FOUND).build();
		}
	}

	@POST
	@Path("/modify")
	@UnitOfWork
	@ApiOperation(value = "Rename a category", notes = "Rename an existing feed category")
	@Timed
	public Response modifyCategory(@ApiParam(hidden = true) @SecurityCheck User user,
			@ApiParam(required = true) CategoryModificationRequest req) {
		Preconditions.checkNotNull(req);
		Preconditions.checkNotNull(req.getId());

		FeedCategory category = feedCategoryDAO.findById(user, req.getId());

		if (StringUtils.isNotBlank(req.getName())) {
			category.setName(req.getName());
		}

		FeedCategory parent = null;
		if (req.getParentId() != null && !CategoryREST.ALL.equals(req.getParentId())
				&& !StringUtils.equals(req.getParentId(), String.valueOf(req.getId()))) {
			parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
		}
		category.setParent(parent);

		if (req.getPosition() != null) {
			List<FeedCategory> categories = feedCategoryDAO.findByParent(user, parent);
			Collections.sort(categories, new Comparator<FeedCategory>() {
				@Override
				public int compare(FeedCategory o1, FeedCategory o2) {
					return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
				}
			});

			int existingIndex = -1;
			for (int i = 0; i < categories.size(); i++) {
				if (Objects.equals(categories.get(i).getId(), category.getId())) {
					existingIndex = i;
				}
			}
			if (existingIndex != -1) {
				categories.remove(existingIndex);
			}

			categories.add(Math.min(req.getPosition(), categories.size()), category);
			for (int i = 0; i < categories.size(); i++) {
				categories.get(i).setPosition(i);
			}
			feedCategoryDAO.saveOrUpdate(categories);
		} else {
			feedCategoryDAO.saveOrUpdate(category);
		}

		feedCategoryDAO.saveOrUpdate(category);
		cache.invalidateUserRootCategory(user);
		return Response.ok().build();
	}

	@POST
	@Path("/collapse")
	@UnitOfWork
	@ApiOperation(value = "Collapse a category", notes = "Save collapsed or expanded status for a category")
	@Timed
	public Response collapseCategory(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) CollapseRequest req) {
		Preconditions.checkNotNull(req);
		Preconditions.checkNotNull(req.getId());

		FeedCategory category = feedCategoryDAO.findById(user, req.getId());
		if (category == null) {
			return Response.status(Status.NOT_FOUND).build();
		}
		category.setCollapsed(req.isCollapse());
		feedCategoryDAO.saveOrUpdate(category);
		cache.invalidateUserRootCategory(user);
		return Response.ok().build();
	}

	@GET
	@Path("/unreadCount")
	@UnitOfWork
	@ApiOperation(value = "Get unread count for feed subscriptions", response = UnreadCount.class, responseContainer = "List")
	@Timed
	public Response getUnreadCount(@ApiParam(hidden = true) @SecurityCheck User user) {
		Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
		return Response.ok(Lists.newArrayList(unreadCount.values())).build();
	}

	@GET
	@Path("/get")
	@UnitOfWork
	@ApiOperation(value = "Get root category", notes = "Get all categories and subscriptions of the user", response = Category.class)
	@Timed
	public Response getRootCategory(@ApiParam(hidden = true) @SecurityCheck User user) {
		Category root = cache.getUserRootCategory(user);
		if (root == null) {
			log.debug("tree cache miss for {}", user.getId());
			List<FeedCategory> categories = feedCategoryDAO.findAll(user);
			List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
			Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);

			root = buildCategory(null, categories, subscriptions, unreadCount);
			root.setId("all");
			root.setName("All");
			cache.setUserRootCategory(user, root);
		}

		return Response.ok(root).build();
	}

	private Category buildCategory(Long id, List<FeedCategory> categories, List<FeedSubscription> subscriptions,
			Map<Long, UnreadCount> unreadCount) {
		Category category = new Category();
		category.setId(String.valueOf(id));
		category.setExpanded(true);

		for (FeedCategory c : categories) {
			if ((id == null && c.getParent() == null) || (c.getParent() != null && Objects.equals(c.getParent().getId(), id))) {
				Category child = buildCategory(c.getId(), categories, subscriptions, unreadCount);
				child.setId(String.valueOf(c.getId()));
				child.setName(c.getName());
				child.setPosition(c.getPosition());
				if (c.getParent() != null && c.getParent().getId() != null) {
					child.setParentId(String.valueOf(c.getParent().getId()));
				}
				child.setExpanded(!c.isCollapsed());
				category.getChildren().add(child);
			}
		}
		Collections.sort(category.getChildren(), new Comparator<Category>() {
			@Override
			public int compare(Category o1, Category o2) {
				return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
			}
		});

		for (FeedSubscription subscription : subscriptions) {
			if ((id == null && subscription.getCategory() == null)
					|| (subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id))) {
				UnreadCount uc = unreadCount.get(subscription.getId());
				Subscription sub = Subscription.build(subscription, config.getApplicationSettings().getPublicUrl(), uc);
				category.getFeeds().add(sub);
			}
		}
		Collections.sort(category.getFeeds(), new Comparator<Subscription>() {
			@Override
			public int compare(Subscription o1, Subscription o2) {
				return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
			}
		});
		return category;
	}

}