package com.pingcap.community.blogserver.service;

import com.pingcap.community.blogserver.action.post.PostActionEvent;
import com.pingcap.community.blogserver.action.post.PostActionMethod;
import com.pingcap.community.blogserver.config.BlogConfig;
import com.pingcap.community.blogserver.dto.CreatePostDTO;
import com.pingcap.community.blogserver.dto.RejectPostDTO;
import com.pingcap.community.blogserver.dto.UpdatePostDTO;
import com.pingcap.community.blogserver.entity.*;
import com.pingcap.community.blogserver.enums.PostAction;
import com.pingcap.community.blogserver.enums.authority.PostAuthority;
import com.pingcap.community.blogserver.enums.PostStatus;
import com.pingcap.community.blogserver.exception.post.*;
import com.pingcap.community.blogserver.exception.SessionInvalidException;
import com.pingcap.community.blogserver.exception.share.ShareFailedToGenerateSlug;
import com.pingcap.community.blogserver.exception.user.UserNotFound;
import com.pingcap.community.blogserver.qo.PostAdvancedQuery;
import com.pingcap.community.blogserver.qo.PostQuery;
import com.pingcap.community.blogserver.qo.UserPostQuery;
import com.pingcap.community.blogserver.repository.*;
import com.pingcap.community.blogserver.support.action.ActionMessage;
import com.pingcap.community.blogserver.support.action.ActionTarget;
import com.pingcap.community.blogserver.util.ContextUtils;
import com.pingcap.community.blogserver.util.TimeUtils;

import javax.persistence.EntityManager;

import com.pingcap.community.blogserver.vo.post.*;
import com.pingcap.community.blogserver.vo.share.SharePostVO;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import javax.transaction.Transactional;
import javax.validation.constraints.NotNull;
import java.util.*;

import static com.pingcap.community.blogserver.qo.PostAdvancedQuery.Sorter.LATEST;
import static com.pingcap.community.blogserver.qo.PostAdvancedQuery.Sorter.RECOMMENDED;

@Slf4j
@AllArgsConstructor
@Service
public class PostService {

  private static final String DEFAULT_TITLE = "未命名的文章";

  private final PostEventRepository postEventRepository;

  private final PostRepository postRepository;

  private final PostAdvancedRepository postAdvancedRepository;

  private final LikeRepository likeRepository;

  private final FavoriteRepository favoriteRepository;

  private final VisitRepository visitRepository;

  private final ShareRepository shareRepository;

  private final CommentRepository commentRepository;

  private final BlogConfig blogConfig;

  private final EntityManager em;

  public Page<Post> getPosts(PostAdvancedQuery query, Pageable pageable) {
    Post post = new Post();
    if (query.getTagID() != null) {
      Long tagID = query.getTagID();
      post.setTags(Collections.singleton(new Tag(tagID)));
    }
    if (query.getCategoryID() != null) {
      post.setCategory(new Category(query.getCategoryID()));
    }
    if (query.getStatus() != null) {
      post.setStatus(query.getStatus());
    }

    Example<Post> example = Example.of(post);
    return postRepository.findAll(example, pageable);
  }

  // Notice: Only the published posts can be queried.
  public Page<PostItemBasicVO> getLatestPosts(PostQuery postQuery, Pageable pageable) {
    PostAdvancedQuery queryParam = new PostAdvancedQuery(postQuery);
    queryParam.setStatus(PostStatus.PUBLISHED);
    queryParam.setSorter(LATEST);
    return postAdvancedRepository.queryPosts(queryParam, pageable);
  }

  public Page<PostItemRawTextVO> getLatestRawTextPosts(PostQuery postQuery, Pageable pageable) {
    PostAdvancedQuery queryParam = new PostAdvancedQuery(postQuery);
    queryParam.setStatus(PostStatus.PUBLISHED);
    queryParam.setSorter(LATEST);
    return postAdvancedRepository.queryRawTextPosts(queryParam, pageable);
  }

  public Page<PostItemMarkdownVO> getLatestMarkdownPosts(PostQuery postQuery, Pageable pageable) {
    PostAdvancedQuery queryParam = new PostAdvancedQuery(postQuery);
    queryParam.setStatus(PostStatus.PUBLISHED);
    queryParam.setSorter(LATEST);
    return postAdvancedRepository.queryMarkdownPosts(queryParam, pageable);
  }

  public Page<PostItemHTMLVO> getLatestHTMLPosts(PostQuery postQuery, Pageable pageable) {
    PostAdvancedQuery queryParam = new PostAdvancedQuery(postQuery);
    queryParam.setStatus(PostStatus.PUBLISHED);
    queryParam.setSorter(LATEST);
    return postAdvancedRepository.queryHTMLPosts(queryParam, pageable);
  }

  // Notice: Only the published posts can be recommended and queried.
  public Page<PostItemBasicVO> getRecommendPosts(PostQuery postQuery, Pageable pageable) {
    PostAdvancedQuery queryParam = new PostAdvancedQuery(postQuery);
    queryParam.setStatus(PostStatus.PUBLISHED);
    queryParam.setSorter(RECOMMENDED);
    return postAdvancedRepository.queryPosts(queryParam, pageable);
  }

  /**
   * getUserPosts
   *
   * Query a single user's posts.
   *
   * @param user Whose posts do you want to query.
   * @param query Set filters.
   * @param pageable Set pagers and sorters, by default, the LATEST sorter is used to
   *                 sort posts in desc order by publish time.
   * @param viewer Which user request this query.
   * @return The Posts.
   * @throws UserNotFound User not found.
   */
  public Page<PostItemBasicVO> getUserPosts(
    User user, UserPostQuery query, Pageable pageable, User viewer
  ) throws UserNotFound {
    if (user == null || user.getId() == null) {
      throw new UserNotFound();
    }

    PostAdvancedQuery queryParam = new PostAdvancedQuery();
    queryParam.setAuthorID(user.getId());
    queryParam.setTagID(query.getTagID());
    queryParam.setCategoryID(query.getCategoryID());

    boolean isAuthor = user.equals(viewer);
    boolean canReadOthersPost = viewer != null && viewer.can(PostAuthority.READ_OTHERS_POST);
    if (isAuthor || canReadOthersPost) {
      if (query.getStatus() != null) {
        queryParam.setStatus(query.getStatus());
      }
    } else {
      queryParam.setStatus(PostStatus.PUBLISHED);
    }

    if (!pageable.getSort().isSorted()) {
      queryParam.setSorter(LATEST);
    }

    return postAdvancedRepository.queryPosts(queryParam, pageable);
  }

  public Post getPostByID(Long postID) throws PostNotFound {
    Optional<Post> postOptional = postRepository.findById(postID);
    if (postOptional.isEmpty()) {
      throw new PostNotFound();
    }
    return postOptional.get();
  }

  public PostDetailVO getPostDetailByID(User viewer, Long postID) throws PostNotFound, PostNotPublished {
    Optional<Post> postOptional = postRepository.findById(postID);
    if (postOptional.isEmpty()) {
      throw new PostNotFound();
    }
    Post post = postOptional.get();

    checkPostIsAccessible(post, viewer);

    return wrapperPostDetail(post, viewer);
  }

  public PostDetailVO getPostDetailBySlug(User viewer, String slug) throws PostNotFound, PostNotPublished {
    Optional<Post> postOptional = postRepository.findBySlug(slug);
    if (postOptional.isEmpty()) {
      throw new PostNotFound();
    }
    Post post = postOptional.get();

    checkPostIsAccessible(post, viewer);

    return wrapperPostDetail(post, viewer);
  }

  private void checkPostIsAccessible(Post post, User viewer) {
    if (!post.isPublic()) {
      if (viewer == null) {
        throw new PostCanNotAccess();
      }
      if (!post.isAuthor(viewer) && !viewer.can(PostAuthority.READ_OTHERS_POST)) {
        throw new PostCanNotAccess();
      }
    }
  }

  private PostDetailVO wrapperPostDetail(Post post, User viewer) {
    Long postID = post.getId();
    String rawText = Optional.ofNullable(post.getRawText()).orElse("");
    post.setSummary(rawText.length() > 100 ? rawText.substring(0, 97) + "..." : rawText);

    long likes = likeRepository.countByPostId(postID);
    boolean liked = Optional.ofNullable(viewer).map(v -> likeRepository.existsByPostIdAndUserId(postID, v.getId())).orElse(false);
    long favorites = favoriteRepository.countByPostId(postID);
    boolean favorited = Optional.ofNullable(viewer).map(v -> favoriteRepository.existsByPostIdAndUserId(postID, v.getId())).orElse(false);
    long shares = shareRepository.countByPostId(postID);
    long visits = visitRepository.countByPostId(postID);
    long comments = commentRepository.countByPostIdAndDeletedAtIsNull(postID);

    if (PostStatus.REJECTED.equals(post.getStatus())) {
      Optional<PostEvent> eventOptional = postEventRepository.findFirstByPostAndActionOrderByTimeDesc(post, PostAction.REJECT);
      eventOptional.ifPresent(postEvent -> post.setRejectReason(postEvent.getMessage()));
    }

    PostDetailVO postDetailVO = new PostDetailVO();
    postDetailVO.setPost(post);
    postDetailVO.setLikes(likes);
    postDetailVO.setLiked(liked);
    postDetailVO.setFavorites(favorites);
    postDetailVO.setFavorited(favorited);
    postDetailVO.setShares(shares);
    postDetailVO.setVisits(visits);
    postDetailVO.setComments(comments);

    return postDetailVO;
  }

  public int countUserCurrentYearPublishedPosts(User author) {
    Date from = TimeUtils.getCurrentYearStartTimeWithoutOffset();
    Date to = TimeUtils.getCurrentYearEndTimeoutWithOffset();
    return postRepository.countByAuthorIdAndStatusAndPublishedAtBetween(
        author.getId(), PostStatus.PUBLISHED, from, to
    );
  }

  @PostActionMethod(action = PostAction.CREATE, targetStatus = PostStatus.DRAFT)
  @Transactional
  public Post createPost(@NotNull User creator, CreatePostDTO createPostDTO) {
    Post post = new Post();

    if (createPostDTO.getTitle() == null || createPostDTO.getTitle().isEmpty()) {
      createPostDTO.setTitle(DEFAULT_TITLE);
    }

    post.setSlug(getUniqueSlug());
    post.setTitle(createPostDTO.getTitle());
    post.setContent(createPostDTO.getContent());
    post.setOrigin(createPostDTO.getOrigin());
    post.setStatus(PostStatus.DRAFT);
    post.setVersion(0L);
    post.setAuthor(creator);
    post.setSourceURL(createPostDTO.getSourceURL());
    post.setCoverImageURL(createPostDTO.getCoverImageURL());
    post.setCategory(createPostDTO.getCategory());
    List<Tag> tags = createPostDTO.getTags();
    if (tags != null) {
      post.setTags(new HashSet<>(tags));
    }

    return postRepository.save(post);
  }

  /**
   * getUniqueSlug
   *
   * This function is used to generate 8 char length random string.
   *
   * @return random string
   */
  private String getUniqueSlug() {
    String randomId = postRepository.getRandomId();
    if (randomId == null) {
      throw new PostFailedToGenerateSlug();
    }
    return randomId;
  }

  /**
   * getUniqueShareID
   *
   * This function is used to generate 8 char length random string.
   *
   * @return random string
   */
  private String getUniqueShareID() {
    String randomId = shareRepository.getRandomId();
    if (randomId == null) {
      throw new ShareFailedToGenerateSlug();
    }
    return randomId;
  }

  @PostActionMethod(action = PostAction.SAVE, requiredStatuses = { PostStatus.DRAFT, PostStatus.REJECTED }, targetStatus = PostStatus.DRAFT)
  @Transactional
  public Post savePost(@ActionTarget Post post, User operator, UpdatePostDTO updatePostDTO) {
    post.setTitle(updatePostDTO.getTitle());
    post.setContent(updatePostDTO.getContent());
    post.setSourceURL(updatePostDTO.getSourceURL());
    post.setCoverImageURL(updatePostDTO.getCoverImageURL());
    post.setCategory(updatePostDTO.getCategory());
    post.setTags(updatePostDTO.getTags());
    post.setOrigin(updatePostDTO.getOrigin());

    return postRepository.save(post);
  }

  private void checkPostRequiredFields(Post post) {
    if (post.getCategory() == null) {
      throw new PostMustHasCategory();
    }
    if (post.getContent() == null || post.getContent().isEmpty()) {
      throw new PostContentMustNotEmpty();
    }
  }

  @PostActionMethod(action = PostAction.SUBMIT, requiredStatuses = PostStatus.DRAFT, targetStatus = PostStatus.PENDING)
  @Transactional
  public void submitPost(Post post) {
    checkPostRequiredFields(post);
  }

  @PostActionMethod(action = PostAction.SUBMIT_CANCEL, requiredStatuses = PostStatus.PENDING, targetStatus = PostStatus.DRAFT)
  @Transactional
  public void cancelSubmitPost(Post post) {
  }

  @PostActionMethod(action = PostAction.PUBLISH, requiredStatuses = { PostStatus.PENDING, PostStatus.DRAFT }, targetStatus = PostStatus.PUBLISHED)
  @Transactional
  public void publishPost(User publisher, @ActionTarget Post post) {
    checkPostRequiredFields(post);
    if (post.getPublishedAt() == null) {
      post.setPublishedAt(new Date());
    }
    post.setLastPublisher(publisher);
    post.setLastPublishedAt(new Date());
  }

  @PostActionMethod(action = PostAction.PUBLISH_CANCEL, requiredStatuses = PostStatus.PUBLISHED, targetStatus = PostStatus.DRAFT)
  @Transactional
  public void cancelPublishPost(@ActionTarget Post post) {
  }

  @PostActionMethod(action = PostAction.REJECT, requiredStatuses = PostStatus.PENDING, targetStatus = PostStatus.REJECTED)
  @Transactional
  public void rejectPost(Post post, @ActionMessage("reason") RejectPostDTO rejectPostDTO) {
    if (rejectPostDTO != null) {
      post.setRejectReason(rejectPostDTO.getReason());
    }
  }

  @PostActionMethod(action = PostAction.DELETE)
  @Transactional
  public void deletePost(Post post) throws PostNotFound {
    if (post.getDeletedAt() != null) {
      throw new PostNotFound();
    }

    postRepository.delete(post);
  }

  @PostActionMethod(action = PostAction.RECOMMEND, requiredStatuses = {PostStatus.PUBLISHED})
  @Transactional
  public void recommendPost(Post post, User recommender) throws PostHasBeenRecommend {
    if (post.getRecommended() != null && post.getRecommended()) {
      throw new PostHasBeenRecommend();
    }

    post.setRecommended(true);
    post.setRecommendAt(new Date());
    post.setRecommender(recommender);
  }

  @PostActionMethod(action = PostAction.RECOMMEND_CANCEL, requiredStatuses = PostStatus.PUBLISHED)
  @Transactional
  public void cancelRecommendPost(Post post) throws PostHasNotBeenRecommend {
    if (post.getRecommended() == null || !post.getRecommended()) {
      throw new PostHasBeenRecommend();
    }

    post.setRecommended(false);
    post.setRecommendAt(null);
    post.setRecommender(null);
  }

  public boolean visitPost(
    Post post, User visitor, String IPAddress, String sessionID, String ssid, Share share
  ) throws SessionInvalidException, PostNotPublished {
    if (!post.isPublic()) {
      throw new PostNotPublished();
    }

    boolean visited;
    switch (blogConfig.getVisitUnrepeatedMode()) {
      case IP:
        if (IPAddress == null || IPAddress.isEmpty()) {
          return false;
        }
        visited = visitRepository.existsByPostAndIPAddress(post, IPAddress);
        break;
      case SESSION_ID:
        if (sessionID == null || sessionID.isEmpty()) {
          return false;
        }
        visited = visitRepository.existsByPostAndSessionID(post, sessionID);
        break;
      case SSID:
        if (ssid == null || ssid.isEmpty()) {
          return false;
        }
        visited = visitRepository.existsByPostAndSsid(post, ssid);
        break;
      default:
        return false;
    }

    if (visited) {
      return false;
    }

    Visit visit = new Visit();
    visit.setPost(post);
    visit.setVisitedAt(new Date());
    visit.setUser(visitor);
    visit.setIPAddress(IPAddress);
    visit.setSessionID(sessionID);
    visit.setSsid(ssid);
    visit.setShare(share);

    visitRepository.save(visit);
    return true;
  }

  public SharePostVO sharePost(Post post, User operator, String sessionID) throws SessionInvalidException, PostNotPublished {
    if (sessionID == null || sessionID.isEmpty()) {
      throw new SessionInvalidException();
    }
    if (!post.isPublic()) {
      throw new PostNotPublished();
    }

    Optional<Share> shareOptional = shareRepository.findByUserAndPost(operator, post);
    Share share;
    boolean shared = shareOptional.isPresent();

    if (!shared) {
      Share newShare = new Share();
      newShare.setId(getUniqueShareID());
      newShare.setSessionID(sessionID);
      newShare.setPost(post);
      newShare.setSharedAt(new Date());
      newShare.setUser(operator);
      share = shareRepository.save(newShare);
    } else {
      share = shareOptional.get();
    }

    SharePostVO sharePostVO = new SharePostVO();
    sharePostVO.setShareID(share.getId());
    sharePostVO.setShared(shared);

    return sharePostVO;
  }

  public Page<PostEventItemVO> getPostEvents(Post post, User viewer, Pageable pageable) throws PostCanNotAccess {
    if (!post.isPublic()) {
      if (!post.isAuthor(viewer) && !viewer.can(PostAuthority.READ_OTHERS_POST)) {
        throw new PostCanNotAccess();
      }
    }

    Page<PostEvent> postEventPage = postEventRepository.findAll(pageable);

    return postEventPage.map((postEvent) -> {
      PostEventItemVO postEventItemVO = new PostEventItemVO();
      postEventItemVO.setId(postEvent.getId());
      postEventItemVO.setAction(postEvent.getAction());
      postEventItemVO.setMessage(postEvent.getMessage());
      postEventItemVO.setTime(postEvent.getTime());

      // TODO: add actor.

      return postEventItemVO;
    });
  }

  @TransactionalEventListener(value = PostActionEvent.class, phase = TransactionPhase.BEFORE_COMMIT)
  public void onPostAction (PostActionEvent event) {
    this.auditPost(event.getAction(), event.getTarget(), event.getMessage());
    log.info("user<{}> perform {} on post<{}>: {}", ContextUtils.getCurrentUserId().orElse(null), event.getAction(), event.getTarget().getId(), event.getMessage());
  }

  private void auditPost(PostAction action, Post post, String message) {
    if (!em.isJoinedToTransaction()) {
      log.warn("action {} for post<{}> was triggered in non transaction context, which could lead to data conflict.", action, post.getId());
    }
    PostEvent postEvent = new PostEvent();
    postEvent.setPost(post);
    postEvent.setAction(action);
    postEvent.setMessage(Optional.ofNullable(message).orElseGet(action::name));
    postEventRepository.save(postEvent);
  }

}
