package run.halo.app.endpoint.uc.content;

import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;

import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
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.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.content.SnapshotService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.JsonUtils;

@Component
public class UcPostEndpoint implements CustomEndpoint {

    private static final String CONTENT_JSON_ANNO = "content.halo.run/content-json";

    private final PostService postService;

    private final SnapshotService snapshotService;

    public UcPostEndpoint(PostService postService, SnapshotService snapshotService) {
        this.postService = postService;
        this.snapshotService = snapshotService;
    }

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        var tag = groupVersion() + "/Post";
        var namePathParam = parameterBuilder().name("name")
            .description("Post name")
            .in(ParameterIn.PATH)
            .required(true);
        return route().nest(
                path("/posts"),
                () -> route()
                    .GET(this::listMyPost, builder -> {
                            builder.operationId("ListMyPosts")
                                .description("List posts owned by the current user.")
                                .tag(tag)
                                .response(responseBuilder().implementation(
                                    ListResult.generateGenericClass(ListedPost.class)));
                            buildParametersFromType(builder, PostQuery.class);
                        }
                    )
                    .POST(this::createMyPost, builder -> builder.operationId("CreateMyPost")
                        .tag(tag)
                        .description("""
                            Create my post. If you want to create a post with content, please set
                             annotation: "content.halo.run/content-json" into annotations and refer
                             to Content for corresponding data type.
                            """)
                        .requestBody(requestBodyBuilder().implementation(Post.class))
                        .response(responseBuilder().implementation(Post.class))
                    )
                    .GET("/{name}", this::getMyPost, builder -> builder.operationId("GetMyPost")
                        .tag(tag)
                        .parameter(namePathParam)
                        .description("Get post that belongs to the current user.")
                        .response(responseBuilder().implementation(Post.class))
                    )
                    .PUT("/{name}", this::updateMyPost, builder ->
                        builder.operationId("UpdateMyPost")
                            .tag(tag)
                            .parameter(namePathParam)
                            .description("Update my post.")
                            .requestBody(requestBodyBuilder().implementation(Post.class))
                            .response(responseBuilder().implementation(Post.class))
                    )
                    .GET("/{name}/draft", this::getMyPostDraft, builder -> builder.tag(tag)
                        .operationId("GetMyPostDraft")
                        .description("Get my post draft.")
                        .parameter(namePathParam)
                        .parameter(parameterBuilder()
                            .name("patched")
                            .in(ParameterIn.QUERY)
                            .required(false)
                            .implementation(Boolean.class)
                            .description("Should include patched content and raw or not.")
                        )
                        .response(responseBuilder().implementation(Snapshot.class))
                    )
                    .PUT("/{name}/draft", this::updateMyPostDraft, builder -> builder.tag(tag)
                        .operationId("UpdateMyPostDraft")
                        .description("""
                            Update draft of my post. Please make sure set annotation: 
                            "content.halo.run/content-json" into annotations and refer to 
                            Content for corresponding data type.
                             """)
                        .parameter(namePathParam)
                        .requestBody(requestBodyBuilder().implementation(Snapshot.class))
                        .response(responseBuilder().implementation(Snapshot.class)))
                    .PUT("/{name}/publish", this::publishMyPost, builder -> builder.tag(tag)
                        .operationId("PublishMyPost")
                        .description("Publish my post.")
                        .parameter(namePathParam)
                        .response(responseBuilder().implementation(Post.class)))
                    .PUT("/{name}/unpublish", this::unpublishMyPost, builder -> builder.tag(tag)
                        .operationId("UnpublishMyPost")
                        .description("Unpublish my post.")
                        .parameter(namePathParam)
                        .response(responseBuilder().implementation(Post.class)))
                    .build(),
                builder -> {
                })
            .build();
    }

    private Mono<ServerResponse> getMyPostDraft(ServerRequest request) {
        var name = request.pathVariable("name");
        var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false);
        var draft = getMyPost(name)
            .flatMap(post -> {
                var headSnapshotName = post.getSpec().getHeadSnapshot();
                var baseSnapshotName = post.getSpec().getBaseSnapshot();
                if (StringUtils.isBlank(headSnapshotName)) {
                    headSnapshotName = baseSnapshotName;
                }
                if (patched) {
                    return snapshotService.getPatchedBy(headSnapshotName, baseSnapshotName);
                }
                return snapshotService.getBy(headSnapshotName);
            });
        return ServerResponse.ok().body(draft, Snapshot.class);
    }

    private Mono<ServerResponse> unpublishMyPost(ServerRequest request) {
        var name = request.pathVariable("name");
        var postMono = getCurrentUser()
            .flatMap(username -> postService.getByUsername(name, username));
        var unpublishedPost = postMono.flatMap(postService::unpublish);
        return ServerResponse.ok().body(unpublishedPost, Post.class);
    }

    private Mono<ServerResponse> publishMyPost(ServerRequest request) {
        var name = request.pathVariable("name");
        var postMono = getCurrentUser()
            .flatMap(username -> postService.getByUsername(name, username));

        var publishedPost = postMono.flatMap(postService::publish);
        return ServerResponse.ok().body(publishedPost, Post.class);
    }

    private Mono<ServerResponse> updateMyPostDraft(ServerRequest request) {
        var name = request.pathVariable("name");
        var postMono = getMyPost(name).cache();
        var snapshotMono = request.bodyToMono(Snapshot.class).cache();

        var contentMono = snapshotMono
            .map(Snapshot::getMetadata)
            .filter(metadata -> {
                var annotations = metadata.getAnnotations();
                return annotations != null && annotations.containsKey(CONTENT_JSON_ANNO);
            })
            .map(metadata -> {
                var contentJson = metadata.getAnnotations().remove(CONTENT_JSON_ANNO);
                return JsonUtils.jsonToObject(contentJson, Content.class);
            })
            .cache();

        // check the snapshot belongs to the post.
        var checkSnapshot = postMono.flatMap(post -> snapshotMono.filter(
                snapshot -> Ref.equals(snapshot.getSpec().getSubjectRef(), post)
            ).switchIfEmpty(Mono.error(() ->
                new ServerWebInputException("The snapshot does not belong to the given post."))
            ).filter(snapshot -> {
                var snapshotName = snapshot.getMetadata().getName();
                var headSnapshotName = post.getSpec().getHeadSnapshot();
                return Objects.equals(snapshotName, headSnapshotName);
            }).switchIfEmpty(Mono.error(() ->
                new ServerWebInputException("The snapshot was not the head snapshot of the post.")))
        ).then();

        var setContributor = getCurrentUser().flatMap(username ->
            snapshotMono.doOnNext(snapshot -> Snapshot.addContributor(snapshot, username)));

        var getBaseSnapshot = postMono.map(post -> post.getSpec().getBaseSnapshot())
            .flatMap(snapshotService::getBy);

        var updatedSnapshot = getBaseSnapshot.flatMap(
            baseSnapshot -> contentMono.flatMap(content -> postMono.flatMap(post -> {
                var postName = post.getMetadata().getName();
                var headSnapshotName = post.getSpec().getHeadSnapshot();
                var releaseSnapshotName = post.getSpec().getReleaseSnapshot();
                if (!Objects.equals(headSnapshotName, releaseSnapshotName)) {
                    // patch and update
                    return snapshotMono.flatMap(
                        s -> snapshotService.patchAndUpdate(s, baseSnapshot, content));
                }
                // patch and create
                return getCurrentUser().map(
                        username -> {
                            var metadata = new Metadata();
                            metadata.setGenerateName(postName + "-snapshot-");
                            var spec = new Snapshot.SnapShotSpec();
                            spec.setParentSnapshotName(headSnapshotName);
                            spec.setOwner(username);
                            spec.setSubjectRef(Ref.of(post));

                            var snapshot = new Snapshot();
                            snapshot.setMetadata(metadata);
                            snapshot.setSpec(spec);
                            Snapshot.addContributor(snapshot, username);
                            return snapshot;
                        })
                    .flatMap(s -> snapshotService.patchAndCreate(s, baseSnapshot, content))
                    .flatMap(createdSnapshot -> {
                        post.getSpec().setHeadSnapshot(createdSnapshot.getMetadata().getName());
                        return postService.updateBy(post).thenReturn(createdSnapshot);
                    });
            })));

        return ServerResponse.ok()
            .body(checkSnapshot.and(setContributor).then(updatedSnapshot), Snapshot.class);
    }

    private Mono<ServerResponse> updateMyPost(ServerRequest request) {
        var name = request.pathVariable("name");

        var postBody = request.bodyToMono(Post.class)
            .doOnNext(post -> {
                var annotations = post.getMetadata().getAnnotations();
                if (annotations != null) {
                    // we don't support updating content while updating post.
                    annotations.remove(CONTENT_JSON_ANNO);
                }
            })
            .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")));

        var updatedPost = getMyPost(name).flatMap(oldPost ->
                postBody.doOnNext(post -> {
                    var oldSpec = oldPost.getSpec();
                    // restrict fields of post.spec.
                    var spec = post.getSpec();
                    spec.setOwner(oldSpec.getOwner());
                    spec.setPublish(oldSpec.getPublish());
                    spec.setPublishTime(oldSpec.getPublishTime());
                    spec.setHeadSnapshot(oldSpec.getHeadSnapshot());
                    spec.setBaseSnapshot(oldSpec.getBaseSnapshot());
                    spec.setReleaseSnapshot(oldSpec.getReleaseSnapshot());
                    spec.setDeleted(oldSpec.getDeleted());
                    post.getMetadata().setName(oldPost.getMetadata().getName());
                }))
            .flatMap(postService::updateBy);
        return ServerResponse.ok().body(updatedPost, Post.class);
    }

    private Mono<ServerResponse> createMyPost(ServerRequest request) {
        var postFromRequest = request.bodyToMono(Post.class)
            .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")));

        var createdPost = getCurrentUser()
            .flatMap(username -> postFromRequest
                .doOnNext(post -> {
                    if (post.getSpec() == null) {
                        post.setSpec(new Post.PostSpec());
                    }
                    post.getSpec().setOwner(username);
                }))
            .map(post -> new PostRequest(post, getContent(post)))
            .flatMap(postService::draftPost);
        return ServerResponse.ok().body(createdPost, Post.class);
    }

    private Content getContent(Post post) {
        Content content = null;
        var annotations = post.getMetadata().getAnnotations();
        if (annotations != null && annotations.containsKey(CONTENT_JSON_ANNO)) {
            var contentJson = annotations.remove(CONTENT_JSON_ANNO);
            content = JsonUtils.jsonToObject(contentJson, Content.class);
        }
        return content;
    }

    private Mono<ServerResponse> listMyPost(ServerRequest request) {
        var posts = getCurrentUser()
            .map(username -> new PostQuery(request, username))
            .flatMap(postService::listPost);
        return ServerResponse.ok().body(posts, ListedPost.class);
    }

    private Mono<ServerResponse> getMyPost(ServerRequest request) {
        var postName = request.pathVariable("name");
        var post = getMyPost(postName);
        return ServerResponse.ok().body(post, Post.class);
    }

    private Mono<Post> getMyPost(String postName) {
        return getCurrentUser()
            .flatMap(username -> postService.getByUsername(postName, username)
                .switchIfEmpty(
                    Mono.error(() -> new NotFoundException("The post was not found or deleted"))
                )
            );
    }

    private Mono<String> getCurrentUser() {
        return ReactiveSecurityContextHolder.getContext()
            .map(SecurityContext::getAuthentication)
            .map(Authentication::getName);
    }

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

}
