package com.airlock.iam.admin.infrastructure.resource.prot.techclient;

import ch.ergon.restal.jsonapi.document.data.ResourceDocument;
import ch.ergon.restal.jsonapi.document.request.RequestDocument;
import com.airlock.iam.admin.infrastructure.generated.transfer.response.techclient.ApiKeyDataResourceDocument;
import com.airlock.iam.admin.infrastructure.transfer.request.techclient.ApiKeyCreateRequest;
import com.airlock.iam.admin.infrastructure.transfer.request.techclient.ApiKeyUpdateRequest;
import com.airlock.iam.admin.infrastructure.transfer.response.techclient.ApiKeyData;
import com.airlock.iam.base.api.domain.model.identity.Identity;
import com.airlock.iam.base.api.domain.model.validation.GenericError;
import com.airlock.iam.base.api.domain.model.validation.ValidationResult;
import com.airlock.iam.base.api.domain.model.validation.ValidatorError;
import com.airlock.iam.base.api.infrastructure.uri.ExternalUriInfo;
import com.airlock.iam.base.infrastructure.error.ErrorResponse;
import com.airlock.iam.base.infrastructure.mapper.ValidationToErrorMapper;
import com.airlock.iam.base.infrastructure.mapper.entity.RestMapper;
import com.airlock.iam.base.infrastructure.validation.RelationshipValidator;
import com.airlock.iam.base.infrastructure.validation.RelationshipValidator.Result;
import com.airlock.iam.base.misc.data.OptionalValue;
import com.airlock.iam.base.misc.date.Dates;
import com.airlock.iam.common.application.service.validation.DateRangeValidator;
import com.airlock.iam.common.domain.model.identity.DefaultStringIdentity;
import com.airlock.iam.common.infrastructure.resource.Resources;
import com.airlock.iam.common.infrastructure.validation.JsonApiUpdateResourceIdValidator;
import com.airlock.iam.core.misc.util.DateRange;
import com.airlock.iam.core.misc.util.DateRange.DateRangeBuilder;
import com.airlock.iam.techclient.admin.application.service.ApiKeyAttributes;
import com.airlock.iam.techclient.admin.application.service.ApiKeyService;
import com.airlock.iam.techclient.admin.application.service.PlainTechClientService;
import com.airlock.iam.techclient.admin.domain.model.ApiKey;
import com.airlock.iam.techclient.api.domain.model.TechnicalClient;
import com.airlock.iam.techclient.domain.model.identity.TechClientIdentityFactory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.annotation.Nullable;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.time.Instant;
import java.util.Date;
import java.util.Optional;

@Path("/api-keys")
public class ApiKeyResource {
   private static final String API_KEY_CREATE_REQ_EXAMPLE = "{\n  \"data\": {\n    \"type\": \"api-key\",\n    \"attributes\": {\n      \"description\": \"My API key description\"\n\t},\n    \"relationships\": {\n      \"tech-client\": {\n        \"data\": {\n          \"type\": \"tech-client\",\n          \"id\": \"189ab3c9-5dbb-401a-b605-741ef5d7503f\"\n        }\n      }\n    }\n  }\n}\n";
   private final Provider<ApiKeyService> apiKeyService;
   private final Provider<PlainTechClientService> techClientService;
   private final Provider<ExternalUriInfo> uriInfo;
   private final RestMapper restMapper;
   private final ValidationToErrorMapper errorMapper;
   private final JsonApiUpdateResourceIdValidator idValidator;

   @Inject
   public ApiKeyResource(
      Provider<ApiKeyService> apiKeyService,
      Provider<PlainTechClientService> techClientService,
      Provider<ExternalUriInfo> uriInfo,
      RestMapper restMapper,
      ValidationToErrorMapper errorMapper,
      JsonApiUpdateResourceIdValidator idValidator
   ) {
      this.apiKeyService = apiKeyService;
      this.techClientService = techClientService;
      this.uriInfo = uriInfo;
      this.restMapper = restMapper;
      this.errorMapper = errorMapper;
      this.idValidator = idValidator;
   }

   @ApiResponses(
      {
            @ApiResponse(
               responseCode = "201",
               description = "A new API key has been created.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class)),
                     @Content(mediaType = "application/json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class))
               }
            ),
            @ApiResponse(
               responseCode = "404",
               description = "The related technical client does not exist.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse")),
                     @Content(mediaType = "application/json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse"))
               }
            )
      }
   )
   @POST
   @Operation(
      description = "Creates a new API key for the specified tech-client.\n<p>\nNote that the request body must contain a resource identifier referring to an existing tech-client that the\nnew API key should be assigned to. See example request for more details.\n</p>\n<div class=\"iam-resource-return\">The created API key.</div>",
      tags = "Technical Clients",
      summary = "Create API key"
   )
   @Consumes({"application/vnd.api+json", "application/json"})
   @Produces({"application/vnd.api+json", "application/json;qs=0.9"})
   public Response create(
      @NotNull @Valid @RequestBody(
   description = "A request containing API key attributes and a relationship to the associated technical client. See example request for mor details.",
   content = {
         @Content(
            mediaType = "application/vnd.api+json",
            examples = @ExampleObject(
               name = "API key with tech-client",
               value = "{\n  \"data\": {\n    \"type\": \"api-key\",\n    \"attributes\": {\n      \"description\": \"My API key description\"\n\t},\n    \"relationships\": {\n      \"tech-client\": {\n        \"data\": {\n          \"type\": \"tech-client\",\n          \"id\": \"189ab3c9-5dbb-401a-b605-741ef5d7503f\"\n        }\n      }\n    }\n  }\n}\n"
            )
         ),
         @Content(
            mediaType = "application/json",
            examples = @ExampleObject(
               name = "API key with tech-client",
               value = "{\n  \"data\": {\n    \"type\": \"api-key\",\n    \"attributes\": {\n      \"description\": \"My API key description\"\n\t},\n    \"relationships\": {\n      \"tech-client\": {\n        \"data\": {\n          \"type\": \"tech-client\",\n          \"id\": \"189ab3c9-5dbb-401a-b605-741ef5d7503f\"\n        }\n      }\n    }\n  }\n}\n"
            )
         )
   }
) RequestDocument<ApiKeyCreateRequest> requestDocument
   ) {
      if (requestDocument.getData().getId() != null) {
         return ErrorResponse.forbidden().log("Cannot accept client-generated ID.").build().toResponse();
      }

      ValidationResult result = this.validateValidityRange((ApiKeyCreateRequest)requestDocument.getDataAttributes());
      if (!result.isValid()) {
         return this.errorMapper.withJsonApiAttributesMapper().mapToResponse(result.getErrors());
      }

      Result relationshipValidation = this.validateRelationship(requestDocument);
      if (relationshipValidation.hasErrors()) {
         return this.errorMapper.mapToResponse(relationshipValidation.getErrors());
      }

      Identity<TechnicalClient> techClientId = TechClientIdentityFactory.techClientId(relationshipValidation.getResourceIdentifier().getId());
      if (((PlainTechClientService)this.techClientService.get()).findTechClient(techClientId).isEmpty()) {
         return ErrorResponse.notFound().build().toResponse();
      }

      ApiKeyAttributes apiKeyAttributes = this.extractAttributes((ApiKeyCreateRequest)requestDocument.getDataAttributes());
      ApiKey apiKey = this.apiKeyService().addApiKey(techClientId, apiKeyAttributes);
      ResourceDocument<ApiKeyData> document = this.restMapper.forEntity(apiKey, ApiKeyData.class).toResourceDocument();
      URI resourceUri = Resources.uri((ExternalUriInfo)this.uriInfo.get(), ApiKeyResource.class).path(apiKey.getId().asString()).build(new Object[0]);
      return Response.created(resourceUri).entity(document).build();
   }

   private ValidationResult validateValidityRange(@Nullable ApiKeyCreateRequest attributes) {
      if (attributes == null) {
         return ValidationResult.valid();
      }

      DateRangeValidator dateRangeValidator = DateRangeValidator.builder().fromFieldName("validFrom").toFieldName("validTo").build();
      return dateRangeValidator.validate(
         DateRange.builder().setFromInstant(Dates.toInstant(attributes.getValidFrom())).setToInstant(Dates.toInstant(attributes.getValidTo())).build()
      );
   }

   private Result validateRelationship(RequestDocument<ApiKeyCreateRequest> requestDocument) {
      RelationshipValidator<ApiKeyCreateRequest> validator = RelationshipValidator.builder().type("tech-client").build();
      return validator.validate(requestDocument);
   }

   private ApiKeyAttributes extractAttributes(@Nullable ApiKeyCreateRequest attributes) {
      return attributes == null
         ? ApiKeyAttributes.builder().build()
         : ApiKeyAttributes.builder()
            .description(attributes.getDescription())
            .validFrom(Optional.ofNullable(attributes.getValidFrom()).map(Date::toInstant).orElse(null))
            .validTo(Optional.ofNullable(attributes.getValidTo()).map(Date::toInstant).orElse(null))
            .locked(Optional.ofNullable(attributes.getLocked()).orElse(false))
            .build();
   }

   @ApiResponses(
      {
            @ApiResponse(
               responseCode = "200",
               description = "The specified API key is returned.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class)),
                     @Content(mediaType = "application/json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class))
               }
            ),
            @ApiResponse(
               responseCode = "404",
               description = "The specified API key does not exist.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse")),
                     @Content(mediaType = "application/json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse"))
               }
            )
      }
   )
   @GET
   @Operation(
      description = "Retrieves the specified API key.\n<div class=\"iam-resource-return\">The API key specified.</div>",
      tags = "Technical Clients",
      summary = "Get API key"
   )
   @Path("/{apiKeyId}")
   @Produces({"application/vnd.api+json", "application/json;qs=0.9"})
   public Response retrieve(@Parameter(description = "The ID of the API key to be retrieved.") @PathParam("apiKeyId") String apiKeyId) {
      Optional<ApiKey> apiKey = this.apiKeyService().findApiKey(DefaultStringIdentity.identity(apiKeyId, ApiKey.class));
      return apiKey.isEmpty() ? ErrorResponse.notFound().build().toResponse() : this.apiKeyResponseDocument(apiKey.get());
   }

   @ApiResponses(
      {
            @ApiResponse(responseCode = "204", description = "The API key has been deleted."),
            @ApiResponse(
               responseCode = "404",
               description = "The API key does not exist.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse")),
                     @Content(mediaType = "application/json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse"))
               }
            )
      }
   )
   @DELETE
   @Operation(
      description = "Deletes an existing API key.\n<div class=\"iam-resource-return\">A response indicating whether the API key was successfully deleted or not.</div>",
      tags = "Technical Clients",
      summary = "Delete API key"
   )
   @Path("/{apiKeyId}")
   @Produces({"application/vnd.api+json", "application/json;qs=0.9"})
   public Response delete(@Parameter(description = "The identification of the API key.") @PathParam("apiKeyId") String apiKeyId) {
      Identity<ApiKey> id = DefaultStringIdentity.identity(apiKeyId, ApiKey.class);
      if (this.apiKeyService().findApiKey(id).isEmpty()) {
         return ErrorResponse.notFound().build().toResponse();
      }

      this.apiKeyService().deleteApiKey(id);
      return Response.noContent().build();
   }

   @ApiResponses(
      {
            @ApiResponse(
               responseCode = "200",
               description = "The API key was updated.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class)),
                     @Content(mediaType = "application/json", schema = @Schema(implementation = ApiKeyDataResourceDocument.class))
               }
            ),
            @ApiResponse(
               responseCode = "404",
               description = "The API key does not exist.",
               content = {
                     @Content(mediaType = "application/vnd.api+json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse")),
                     @Content(mediaType = "application/json", schema = @Schema(ref = "json-api.yaml#/components/schemas/ErrorDocumentResponse"))
               }
            )
      }
   )
   @PATCH
   @Operation(
      description = "Updates the specified API key.\n<div class=\"iam-resource-return\">The updated API key.</div>",
      tags = "Technical Clients",
      summary = "Update API key"
   )
   @Path("/{apiKeyId}")
   @Consumes({"application/vnd.api+json", "application/json"})
   @Produces({"application/vnd.api+json", "application/json;qs=0.9"})
   public Response updateApiKey(
      @Parameter(description = "The ID of the API key.") @PathParam("apiKeyId") String apiKeyId,
      @Parameter(description = "The request containing API key attributes. Omitted attributes are left unchanged.") @NotNull @Valid RequestDocument<ApiKeyUpdateRequest> requestDocument
   ) {
      Optional<Response> errorResponse = this.idValidator.validate(apiKeyId, requestDocument);
      if (errorResponse.isPresent()) {
         return errorResponse.get();
      }

      Optional<ApiKey> apiKey = this.apiKeyService().findApiKey(DefaultStringIdentity.identity(apiKeyId, ApiKey.class));
      if (apiKey.isEmpty()) {
         return ErrorResponse.notFound().build().toResponse();
      }

      ApiKeyUpdateRequest request = (ApiKeyUpdateRequest)requestDocument.getDataAttributes();
      if (request == null) {
         return this.apiKeyResponseDocument(apiKey.get());
      }

      ValidationResult dateRangeValidationResult = validateOptionalValidityRange(request.getValidFrom(), request.getValidTo(), apiKey.get());
      if (!dateRangeValidationResult.isValid()) {
         return this.errorMapper.withJsonApiAttributesMapper().mapToResponse(dateRangeValidationResult.getErrors());
      }

      ApiKey updatedApiKey = this.applyChangedValues(request, apiKey.get());
      updatedApiKey = this.apiKeyService().updateApiKey(updatedApiKey);
      return this.apiKeyResponseDocument(updatedApiKey);
   }

   private static ValidationResult validateOptionalValidityRange(OptionalValue<Date> validFrom, OptionalValue<Date> validTo, ApiKey apiKey) {
      if (validFrom.hasValue() && validTo.hasValue()) {
         DateRangeBuilder builder = DateRange.builder();
         validFrom.ifPresent(date -> builder.setFromInstant(Dates.toInstant(date)));
         validTo.ifPresent(date -> builder.setToInstant(Dates.toInstant(date)));
         DateRangeValidator dateRangeValidator = DateRangeValidator.builder()
            .mandatoryFrom(false)
            .mandatoryTo(false)
            .fromFieldName("validFrom")
            .toFieldName("validTo")
            .build();
         return dateRangeValidator.validate(builder.build());
      }

      Date newValidFrom = (Date)validFrom.value();
      if (isValidFromAfterValidTo(newValidFrom, apiKey.getValidTo())) {
         return ValidationResult.invalid(
            ValidatorError.validatorErrorBuilder("validFrom", GenericError.BEFORE).parameter("before", apiKey.getValidTo()).build()
         );
      }

      Date newValidTo = (Date)validTo.value();
      return isValidToBeforeValidFrom(newValidTo, apiKey.getValidFrom())
         ? ValidationResult.invalid(ValidatorError.validatorErrorBuilder("validTo", GenericError.AFTER).parameter("after", apiKey.getValidFrom()).build())
         : ValidationResult.valid();
   }

   private static boolean isValidToBeforeValidFrom(Date newValidTo, Instant apiKeyValidFrom) {
      return newValidTo != null && apiKeyValidFrom != null && newValidTo.toInstant().isBefore(apiKeyValidFrom);
   }

   private static boolean isValidFromAfterValidTo(Date newValidFrom, Instant apiKeyValidTo) {
      return newValidFrom != null && apiKeyValidTo != null && newValidFrom.toInstant().isAfter(apiKeyValidTo);
   }

   private ApiKey applyChangedValues(ApiKeyUpdateRequest request, ApiKey apiKey) {
      request.getDescription().ifPresent(apiKey::setDescription);
      request.getValidFrom().ifPresent(v -> apiKey.setValidFrom(Optional.ofNullable(v).map(Date::toInstant).orElse(null)));
      request.getValidTo().ifPresent(v -> apiKey.setValidTo(Optional.ofNullable(v).map(Date::toInstant).orElse(null)));
      return apiKey;
   }

   private Response apiKeyResponseDocument(ApiKey apiKey) {
      ResourceDocument<ApiKeyData> document = this.restMapper.forEntity(apiKey, ApiKeyData.class).toResourceDocument();
      return Response.ok().entity(document).build();
   }

   private ApiKeyService apiKeyService() {
      return (ApiKeyService)this.apiKeyService.get();
   }
}
