package run.halo.app.core.endpoint.theme;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.apache.commons.lang3.BooleanUtils.isFalse;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.operation.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.content.comment.CommentRequest;
import run.halo.app.content.comment.CommentService;
import run.halo.app.content.comment.ReplyRequest;
import run.halo.app.content.comment.ReplyService;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.endpoint.SortResolver;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.Ref;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.utils.HaloUtils;
import run.halo.app.infra.utils.IpAddressUtils;
import run.halo.app.theme.finders.CommentFinder;
import run.halo.app.theme.finders.CommentPublicQueryService;
import run.halo.app.theme.finders.vo.CommentVo;
import run.halo.app.theme.finders.vo.CommentWithReplyVo;
import run.halo.app.theme.finders.vo.ReplyVo;

/**
 * Endpoint for {@link CommentFinder}.
 */
@Component
@RequiredArgsConstructor
public class CommentFinderEndpoint implements CustomEndpoint {

    private final CommentPublicQueryService commentPublicQueryService;
    private final CommentService commentService;
    private final ReplyService replyService;
    private final SystemConfigurableEnvironmentFetcher environmentFetcher;
    private final RateLimiterRegistry rateLimiterRegistry;

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        final var tag = "CommentV1alpha1Public";
        return SpringdocRouteBuilder.route()
            .POST("comments", this::createComment,
                builder -> builder.operationId("CreateComment_1")
                    .description("Create a comment.")
                    .tag(tag)
                    .requestBody(requestBodyBuilder()
                        .required(true)
                        .content(contentBuilder()
                            .mediaType(MediaType.APPLICATION_JSON_VALUE)
                            .schema(schemaBuilder()
                                .implementation(CommentRequest.class))
                        ))
                    .response(responseBuilder()
                        .implementation(Comment.class))
            )
            .POST("comments/{name}/reply", this::createReply,
                builder -> builder.operationId("CreateReply_1")
                    .description("Create a reply.")
                    .tag(tag)
                    .parameter(parameterBuilder().name("name")
                        .in(ParameterIn.PATH)
                        .required(true)
                        .implementation(String.class))
                    .requestBody(requestBodyBuilder()
                        .required(true)
                        .content(contentBuilder()
                            .mediaType(MediaType.APPLICATION_JSON_VALUE)
                            .schema(schemaBuilder()
                                .implementation(ReplyRequest.class))
                        ))
                    .response(responseBuilder()
                        .implementation(Reply.class))
            )
            .GET("comments", this::listComments, builder -> {
                builder.operationId("ListComments_1")
                    .description("List comments.")
                    .tag(tag)
                    .response(responseBuilder()
                        .implementation(ListResult.generateGenericClass(CommentWithReplyVo.class))
                    );
                CommentQuery.buildParameters(builder);
            })
            .GET("comments/{name}", this::getComment, builder -> {
                builder.operationId("GetComment")
                    .description("Get a comment.")
                    .tag(tag)
                    .parameter(parameterBuilder().name("name")
                        .in(ParameterIn.PATH)
                        .required(true)
                        .implementation(String.class))
                    .response(responseBuilder()
                        .implementation(ListResult.generateGenericClass(CommentVo.class))
                    );
            })
            .GET("comments/{name}/reply", this::listCommentReplies, builder -> {
                builder.operationId("ListCommentReplies")
                    .description("List comment replies.")
                    .tag(tag)
                    .parameter(parameterBuilder().name("name")
                        .in(ParameterIn.PATH)
                        .required(true)
                        .implementation(String.class))
                    .response(responseBuilder()
                        .implementation(ListResult.generateGenericClass(ReplyVo.class))
                    );
                PageableRequest.buildParameters(builder);
            })
            .build();
    }

    @Override
    public GroupVersion groupVersion() {
        return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
    }

    Mono<ServerResponse> createComment(ServerRequest request) {
        return request.bodyToMono(CommentRequest.class)
            .flatMap(commentRequest -> {
                Comment comment = commentRequest.toComment();
                comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
                comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
                return commentService.create(comment);
            })
            .flatMap(comment -> ServerResponse.ok().bodyValue(comment))
            .transformDeferred(createIpBasedRateLimiter(request))
            .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
    }

    private <T> RateLimiterOperator<T> createIpBasedRateLimiter(ServerRequest request) {
        var clientIp = IpAddressUtils.getIpAddress(request);
        var rateLimiter = rateLimiterRegistry.rateLimiter("comment-creation-from-ip-" + clientIp,
            "comment-creation");
        return RateLimiterOperator.of(rateLimiter);
    }

    Mono<ServerResponse> createReply(ServerRequest request) {
        String commentName = request.pathVariable("name");
        return request.bodyToMono(ReplyRequest.class)
            .flatMap(replyRequest -> {
                Reply reply = replyRequest.toReply();
                reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
                reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
                // fix gh-2951
                reply.getSpec().setHidden(false);
                return environmentFetcher.fetchComment()
                    .map(commentSetting -> {
                        if (isFalse(commentSetting.getEnable())) {
                            throw new AccessDeniedException(
                                "The comment function has been turned off.",
                                "problemDetail.comment.turnedOff", null);
                        }
                        if (checkReplyOwner(reply, commentSetting.getSystemUserOnly())) {
                            throw new AccessDeniedException("Allow only system users to comment.",
                                "problemDetail.comment.systemUsersOnly", null);
                        }
                        reply.getSpec()
                            .setApproved(isFalse(commentSetting.getRequireReviewForNew()));
                        return reply;
                    })
                    .defaultIfEmpty(reply);
            })
            .flatMap(reply -> replyService.create(commentName, reply))
            .flatMap(comment -> ServerResponse.ok().bodyValue(comment))
            .transformDeferred(createIpBasedRateLimiter(request))
            .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
    }

    private boolean checkReplyOwner(Reply reply, Boolean onlySystemUser) {
        Comment.CommentOwner owner = reply.getSpec().getOwner();
        if (isTrue(onlySystemUser)) {
            return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind());
        }
        return false;
    }

    Mono<ServerResponse> listComments(ServerRequest request) {
        CommentQuery commentQuery = new CommentQuery(request);
        return commentPublicQueryService.list(commentQuery.toRef(), commentQuery.toPageRequest())
            .flatMap(result -> {
                if (commentQuery.getWithReplies()) {
                    return commentPublicQueryService.convertToWithReplyVo(result,
                        commentQuery.getReplySize());
                }
                return Mono.just(result);
            })
            .flatMap(list -> ServerResponse.ok().bodyValue(list));
    }

    Mono<ServerResponse> getComment(ServerRequest request) {
        String name = request.pathVariable("name");
        return Mono.defer(() -> Mono.justOrEmpty(commentPublicQueryService.getByName(name)))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
    }

    Mono<ServerResponse> listCommentReplies(ServerRequest request) {
        String commentName = request.pathVariable("name");
        IListRequest.QueryListRequest queryParams =
            new IListRequest.QueryListRequest(request.queryParams());
        return commentPublicQueryService.listReply(commentName, queryParams.getPage(),
                queryParams.getSize())
            .flatMap(list -> ServerResponse.ok().bodyValue(list));
    }

    public static class CommentQuery extends PageableRequest {

        private final ServerWebExchange exchange;

        public CommentQuery(ServerRequest request) {
            super(request.queryParams());
            this.exchange = request.exchange();
        }

        @Schema(description = "The comment subject group.")
        public String getGroup() {
            return queryParams.getFirst("group");
        }

        @Schema(requiredMode = REQUIRED, description = "The comment subject version.")
        public String getVersion() {
            return emptyToNull(queryParams.getFirst("version"));
        }

        /**
         * Gets the {@link Ref}s kind.
         *
         * @return comment subject ref kind
         */
        @Schema(requiredMode = REQUIRED, description = "The comment subject kind.")
        public String getKind() {
            String kind = emptyToNull(queryParams.getFirst("kind"));
            if (kind == null) {
                throw new ServerWebInputException("The kind must not be null.");
            }
            return kind;
        }

        /**
         * Gets the {@link Ref}s name.
         *
         * @return comment subject ref name
         */
        @Schema(requiredMode = REQUIRED, description = "The comment subject name.")
        public String getName() {
            String name = emptyToNull(queryParams.getFirst("name"));
            if (name == null) {
                throw new ServerWebInputException("The name must not be null.");
            }
            return name;
        }

        @Schema(description = "Whether to include replies. Default is false.",
            defaultValue = "false")
        public Boolean getWithReplies() {
            var withReplies = queryParams.getFirst("withReplies");
            return StringUtils.isNotBlank(withReplies) && Boolean.parseBoolean(withReplies);
        }

        @Schema(description = "Reply size of the comment, default is 10, only works when "
            + "withReplies is true.", defaultValue = "10")
        public int getReplySize() {
            var replySize = queryParams.getFirst("replySize");
            return StringUtils.isNotBlank(replySize) ? Integer.parseInt(replySize) : 10;
        }

        @ArraySchema(uniqueItems = true,
            arraySchema = @Schema(name = "sort",
                description = "Sort property and direction of the list result. Supported fields: "
                    + "creationTimestamp"),
            schema = @Schema(description = "like field,asc or field,desc",
                implementation = String.class,
                example = "creationTimestamp,desc"))
        public Sort getSort() {
            return SortResolver.defaultInstance.resolve(exchange);
        }

        Ref toRef() {
            Ref ref = new Ref();
            ref.setGroup(getGroup());
            ref.setKind(getKind());
            ref.setVersion(getVersion());
            ref.setName(getName());
            return ref;
        }

        public PageRequest toPageRequest() {
            return PageRequestImpl.of(getPage(), getSize(), getSort());
        }

        String emptyToNull(String str) {
            return StringUtils.isBlank(str) ? null : str;
        }

        public static void buildParameters(Builder builder) {
            PageableRequest.buildParameters(builder);
            builder.parameter(sortParameter())
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("group")
                    .description("The comment subject group.")
                    .required(false)
                    .implementation(String.class))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("version")
                    .description("The comment subject version.")
                    .required(true)
                    .implementation(String.class))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("kind")
                    .description("The comment subject kind.")
                    .required(true))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("name")
                    .description("The comment subject name.")
                    .required(true)
                    .implementation(String.class))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("withReplies")
                    .description("Whether to include replies. Default is false.")
                    .required(false)
                    .implementation(Boolean.class))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("replySize")
                    .description("Reply size of the comment, default is 10, only works when "
                        + "withReplies is true.")
                    .required(false)
                    .schema(schemaBuilder()
                        .implementation(Integer.class)
                        .defaultValue("10")));
        }
    }

    public static class PageableRequest extends IListRequest.QueryListRequest {

        public PageableRequest(MultiValueMap<String, String> queryParams) {
            super(queryParams);
        }

        @Override
        @JsonIgnore
        public List<String> getLabelSelector() {
            throw new UnsupportedOperationException("Unsupported this parameter");
        }

        @Override
        @JsonIgnore
        public List<String> getFieldSelector() {
            throw new UnsupportedOperationException("Unsupported this parameter");
        }

        public static void buildParameters(Builder builder) {
            builder.parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("page")
                    .implementation(Integer.class)
                    .required(false)
                    .description("Page number. Default is 0."))
                .parameter(parameterBuilder()
                    .in(ParameterIn.QUERY)
                    .name("size")
                    .implementation(Integer.class)
                    .required(false)
                    .description("Size number. Default is 0."));
        }

    }
}
