/*
 *******************************************************************************
 * Copyright (c) 2019 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * 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.eclipse.microprofile.lra.tck.participant.api;

import org.eclipse.microprofile.lra.annotation.Compensate;
import org.eclipse.microprofile.lra.annotation.Complete;
import org.eclipse.microprofile.lra.annotation.Forget;
import org.eclipse.microprofile.lra.annotation.ParticipantStatus;
import org.eclipse.microprofile.lra.annotation.Status;
import org.eclipse.microprofile.lra.annotation.ws.rs.LRA;
import org.eclipse.microprofile.lra.annotation.ws.rs.Leave;
import org.eclipse.microprofile.lra.tck.service.LRAMetricService;
import org.eclipse.microprofile.lra.tck.service.LRAMetricType;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.container.AsyncResponse;
import javax.ws.rs.container.Suspended;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static javax.ws.rs.core.Response.Status.NOT_FOUND;
import static org.eclipse.microprofile.lra.annotation.ws.rs.LRA.LRA_HTTP_CONTEXT_HEADER;
import static org.eclipse.microprofile.lra.annotation.ws.rs.LRA.LRA_HTTP_PARENT_CONTEXT_HEADER;
import static org.eclipse.microprofile.lra.tck.participant.api.NonParticipatingTckResource.SUPPORTS_PATH;
import static org.eclipse.microprofile.lra.tck.participant.api.NonParticipatingTckResource.TCK_NON_PARTICIPANT_RESOURCE_PATH;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;

@ApplicationScoped
@Path(ContextTckResource.TCK_CONTEXT_RESOURCE_PATH)
public class ContextTckResource {
    public static final String TCK_CONTEXT_RESOURCE_PATH = "context-tck-resource";

    public static final String NEW_LRA_PATH = "/new-lra";
    public static final String REQUIRED_LRA_PATH = "/required-lra";
    public static final String NESTED_LRA_PATH = "/nested-lra";
    public static final String NESTED_LRA_PATH_WITH_CLOSE = "/nested-lra-with-close";
    // resource path for testing that context on outbound and inbound calls made from an
    // method annotated with @LRA are spec compliant
    public static final String CONTEXT_CHECK_LRA_PATH = "/context-check-lra";
    public static final String ASYNC_LRA_PATH1 = "async-response-lra";
    public static final String ASYNC_LRA_PATH2 = "completion-stage-lra";
    public static final String ASYNC_LRA_PATH3 = "completion-stage-exceptionally-lra";

    public static final String LEAVE_PATH = "/leave";
    // resource path for reading and writing the participant status
    public static final String STATUS_PATH = "/status";    // resource path for reading and writing the participant status
    public static final String CLEAR_STATUS_PATH = "/clear-status";
    // resource path for reading stats relating to a participant in the context of a single LRA
    public static final String METRIC_PATH = "/count";
    // resource path for clearing state
    public static final String RESET_PATH = "/reset";

    // headers for tests to indicate how the participant should respond to callbacks
    // from the LRA implementation (complete, compensate, forget and status)
    public static final String LRA_TCK_FAULT_TYPE_HEADER = "Lra-Tck-Fault-Type";
    public static final String LRA_TCK_FAULT_CODE_HEADER = "Lra-Tck-Fault-Status";

    // a header for tests to indicate which LRA stats are being queried/reset
    public static final String LRA_TCK_HTTP_CONTEXT_HEADER = "Lra-Tck-Context";

    private static final String REQUIRES_NEW_LRA_PATH = "/requires-new-lra";

    private ExecutorService excecutorService;

    @Inject
    private LRAMetricService lraMetricService;

    /**
     * An enum which controls the behaviour of participant when the
     * LRA spec implementation invokes the compensation, completion,
     * forget and status callbacks. It is used for testing the
     * implementation is spec compliant.
     */
    public enum EndPhase {
        ACCEPTED, // a participant reports that the complete/compensate is in progress
        FAILED, // a particant reports that is is unable to complete/compensate
        SUCCESS // clear the any injected behaviour
    }

    private EndPhase endPhase = EndPhase.SUCCESS;
    // control which status to return from participant callbacks
    private Response.Status endPhaseStatus = Response.Status.OK;
    private ParticipantStatus status = ParticipantStatus.Active;

    // update the injected behaviour
    private void setEndPhase(String faultType, int faultCode) {
        if (faultType == null) {
            endPhase = EndPhase.SUCCESS;
            endPhaseStatus = Response.Status.OK;
        } else {
            endPhase = EndPhase.valueOf(faultType);
            endPhaseStatus = Response.Status.fromStatusCode(faultCode);
        }
    }


    @PostConstruct
    private void postConstruct() {
        excecutorService = Executors.newFixedThreadPool(1);

    }

    @PreDestroy
    private void preDestroy() {
        excecutorService.shutdown();
    }

    private ExecutorService getExcecutorService() {
        return excecutorService;
    }

    // reset any state in preparation for the next test
    @PUT
    @Path(RESET_PATH)
    public Response reset(@HeaderParam(LRA_TCK_HTTP_CONTEXT_HEADER) URI lraId) {
        status = ParticipantStatus.Active;
        endPhase = EndPhase.SUCCESS;
        endPhaseStatus = Response.Status.OK;

        lraMetricService.clear();

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

    // start a new LRA which remains active after the method returns
    @LRA(value = LRA.Type.REQUIRES_NEW, end = false)
    @PUT
    @Path(NEW_LRA_PATH)
    public Response newLRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                           @HeaderParam(LRA_TCK_FAULT_TYPE_HEADER) String tckFaultType,
                           @HeaderParam(LRA_TCK_FAULT_CODE_HEADER) int tckFaultCode) {
        // check for a requests to inject particular behaviour
        setEndPhase(tckFaultType, tckFaultCode);

        return Response.ok().entity(lraId.toASCIIString()).build();
    }

    // end an existing LRA or start and end a new one
    @LRA(value = LRA.Type.REQUIRED)
    @PUT
    @Path(REQUIRED_LRA_PATH)
    public Response requiredLRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                @HeaderParam(LRA_TCK_FAULT_TYPE_HEADER) String tckFaultType,
                                @HeaderParam(LRA_TCK_FAULT_CODE_HEADER) int tckFaultCode) {
        setEndPhase(tckFaultType, tckFaultCode);

        return Response.ok().entity(lraId.toASCIIString()).build();
    }

    @LRA(value = LRA.Type.REQUIRES_NEW)
    @PUT
    @Path(REQUIRES_NEW_LRA_PATH)
    public Response requiresNew(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        return Response.ok().entity(lraId.toASCIIString()).build();
    }

    @LRA(value = LRA.Type.NESTED, end = false)
    @PUT
    @Path(NESTED_LRA_PATH)
    public Response nestedLRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI nestedLRA,
                              @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parentLRA) {
        return Response.ok().entity(nestedLRA.toASCIIString() + "," + parentLRA.toASCIIString()).build();
    }

    @LRA(value = LRA.Type.NESTED)
    @PUT
    @Path(NESTED_LRA_PATH_WITH_CLOSE)
    public Response nestedLRAWithClose(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI nestedLRA,
                              @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parentLRA) {
        return Response.ok().entity(nestedLRA.toASCIIString() + "," + parentLRA.toASCIIString()).build();
    }

    // test that outgoing calls do not affect the calling context
    @LRA(value = LRA.Type.REQUIRED)
    @PUT
    @Path(CONTEXT_CHECK_LRA_PATH)
    public Response contextCheck(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        String remote;
        String active = getActiveLRA();
        assertEquals("contextCheck1: incoming and active LRAs are different", lraId.toASCIIString(), active);

        // invoke a remote service which runs in its own context. Do not set the context header
        remote = restPutInvocation(null, REQUIRES_NEW_LRA_PATH, "");
        assertNotEquals("contextCheck2: the remote service should not have ran with the active context", remote, active);


        assertEquals("contextCheck2: after calling a remote service the active LRAs are different", active, getActiveLRA());

        // invoke a remote service which runs in its own context. Do set the context header
        remote = restPutInvocation(active, REQUIRES_NEW_LRA_PATH, "");
        assertNotEquals("contextCheck3: the remote service should not have ran with the active context", remote, active);
        assertEquals("contextCheck3: after calling a remote service the active LRAs are different", active, getActiveLRA());

        // invoke a remote service which runs in the callers context, ie do set the context header
        remote = restPutInvocation(active, TCK_NON_PARTICIPANT_RESOURCE_PATH, SUPPORTS_PATH, "");
        assertEquals("contextCheck4: the remote service should have ran with the active context", remote, active);
        assertEquals("contextCheck4: after calling a remote service the active LRAs is different", active, getActiveLRA());

        return Response.ok().entity(lraId.toASCIIString()).build();
    }

    @LRA(value = LRA.Type.REQUIRED)
    @PUT
    @Path(ASYNC_LRA_PATH1)
    public void async1LRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                         final @Suspended AsyncResponse ar) {
        excecutorService.submit(() -> {
            // excecute long running business activity and resume when done
            ar.resume(Response.ok().entity(lraId.toASCIIString()).build());
        });
    }

    @LRA(value = LRA.Type.REQUIRED,  // the method must run with an LRA
            end = true, // the LRA must end when the method completes
            cancelOnFamily = Response.Status.Family.SERVER_ERROR, // cancel LRA on any 5xx code
            cancelOn = NOT_FOUND) // cancel LRA on 404
    @PUT
    @Path(ASYNC_LRA_PATH2)
    public CompletionStage<Response> asyncInvocationWithLRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        return CompletableFuture.supplyAsync(
                () -> {
                    try {
                        Thread.sleep(10);
                        return Response.ok().entity(lraId.toASCIIString()).build();
                    } catch (InterruptedException ex) {
                        return Response.status(NOT_FOUND).entity(lraId.toASCIIString()).build();
                    }
                },
                getExcecutorService()
        );
    }

    @LRA(value = LRA.Type.REQUIRED)
    @PUT
    @Path(ASYNC_LRA_PATH3)
    public CompletionStage<Response> async3LRA(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        final CompletableFuture<Response> response = new CompletableFuture<>();

        excecutorService.submit(() -> {
            // excecute long running business activity finishing with an error
            // code of NOT_FOUND which causes the LRA to cancel
            response.completeExceptionally(
                    new WebApplicationException(Response.status(Response.Status.NOT_FOUND).entity(lraId.toASCIIString()).build()));
        });

        return response;
    }

    @Leave
    @PUT
    @Path(LEAVE_PATH)
    public Response leave(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        return Response.ok().entity(lraId.toASCIIString()).build();
    }

    @PUT
    @Path("/compensate")
    @Compensate
    public Response compensateWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                   @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parent) {
        lraMetricService.incrementMetric(LRAMetricType.Compensated, lraId);
        if (parent != null) {
            lraMetricService.incrementMetric(LRAMetricType.Nested, parent);
        }

        return getEndPhaseResponse(false);
    }

    @PUT
    @Path("/complete")
    @Complete
    public Response completeWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                 @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parent) {
        lraMetricService.incrementMetric(LRAMetricType.Completed, lraId);
        if (parent != null) {
            lraMetricService.incrementMetric(LRAMetricType.Nested, parent);
        }

        return getEndPhaseResponse(true);
    }

    @GET
    @Path(STATUS_PATH)
    @Status
    public Response status(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                           @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parent) {
        lraMetricService.incrementMetric(LRAMetricType.Status, lraId);
        if (parent != null) {
            lraMetricService.incrementMetric(LRAMetricType.Nested, parent);
        }

        return Response.status(endPhaseStatus).entity(status.name()).build();
    }

    @DELETE
    @Path("/forget")
    @Forget
    public Response forget(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId,
                           @HeaderParam(LRA_HTTP_PARENT_CONTEXT_HEADER) URI parent) {
        lraMetricService.incrementMetric(LRAMetricType.Forget, lraId);
        if (parent != null) {
            lraMetricService.incrementMetric(LRAMetricType.Nested, parent);
        }

        return Response.status(endPhaseStatus).entity(status.name()).build();
    }

    // clear any injected participant behaviour
    @POST
    @Path(CLEAR_STATUS_PATH)
    public Response clearStatus(@HeaderParam(LRA_TCK_HTTP_CONTEXT_HEADER) URI lraId,
                           @HeaderParam(LRA_TCK_FAULT_TYPE_HEADER) String tckFaultType) {
        switch (status) {
            case Compensating:
                status = ParticipantStatus.Compensated;
                break;
            case Completing:
                status = ParticipantStatus.Completed;
                break;
            default:
                break; // do nothing
        }

        endPhase = EndPhase.SUCCESS;
        endPhaseStatus = Response.Status.OK;

        return Response.ok().entity(status.name()).build();
    }

    // modify participant responses based on injected behaviour specified by the test
    private Response getEndPhaseResponse(boolean completing) {
        switch (endPhase) {
            case ACCEPTED:
                status = completing ? ParticipantStatus.Completing : ParticipantStatus.Compensating;
                return Response.status(Response.Status.ACCEPTED).entity(status.name()).build();
            case FAILED:
                status = completing ? ParticipantStatus.FailedToComplete : ParticipantStatus.FailedToCompensate;
                return Response.status(endPhaseStatus).entity(status.name()).build();
            case SUCCESS: /* FALLTHRU */
            default:
                status = completing ? ParticipantStatus.Completed : ParticipantStatus.Compensated;
                return Response.status(Response.Status.OK).entity(status.name()).build();
        }
    }

    /*
     * helper methods for testing how outgoing/incoming JAX-RS calls affect the notion of the current context
     */

    @Context
    private UriInfo context;

    private String getActiveLRA() {
        return restPutInvocation(null, TCK_NON_PARTICIPANT_RESOURCE_PATH, SUPPORTS_PATH, "");
    }

    private String restPutInvocation(String lraURI, String path, String bodyText) {
        return restPutInvocation(lraURI, TCK_CONTEXT_RESOURCE_PATH, path, bodyText);
    }

    private String restPutInvocation(String lraURI, String resource, String path, String bodyText) {
        String id = null;
        Invocation.Builder builder = ClientBuilder.newClient()
                .target(context.getBaseUri())
                .path(resource)
                .path(path)
                .request();
        if (lraURI != null) {
            builder.header(LRA_HTTP_CONTEXT_HEADER, lraURI);
        }

        Response response = builder.put(Entity.text(bodyText));

        if (response.hasEntity()) {
            id = response.readEntity(String.class);
        }

        try {
            if (response.getStatus() != Response.Status.OK.getStatusCode()) {
                throw new WebApplicationException("Error on REST PUT for LRA '" + lraURI
                        + "' at path '" + path + "' and body '" + bodyText + "'", response);
            }
        } finally {
            response.close();
        }

        return id;
    }
}