/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.nifi.cluster.manager;

import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.StreamingOutput;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.cluster.protocol.NodeIdentifier;
import org.apache.nifi.web.api.entity.Entity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.InputStream;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * Encapsulates a node's response in regards to receiving a external API request.
 *
 * Both the ClientResponse and (server) Response may be obtained from this instance. The ClientResponse is stored as it is received from the node. This includes the entity input stream. The Response
 * is constructed on demand when mapping a ClientResponse to the Response. The ClientResponse to Response mapping includes copying the ClientResponse's input stream to the Response. Therefore, the
 * getResponse() method should not be called more than once. Furthermore, the method should not be called if the caller has already read the ClientResponse's input stream.
 *
 * If a ClientResponse was unable to be created, then a NodeResponse will store the Throwable, which may be obtained by calling getThrowable().
 *
 * This class overrides hashCode and equals and considers two instances to be equal if they have the equal NodeIdentifiers.
 *
 */
public class NodeResponse {

    private static final Logger logger = LoggerFactory.getLogger(NodeResponse.class);
    private final String httpMethod;
    private final URI requestUri;
    private final Response response;
    private final NodeIdentifier nodeId;
    private Throwable throwable;
    private boolean hasCreatedResponse = false;
    private final Entity updatedEntity;
    private final long requestDurationNanos;
    private final String requestId;

    public NodeResponse(final NodeIdentifier nodeId, final String httpMethod, final URI requestUri, final Response response, final long requestDurationNanos, final String requestId) {
        if (nodeId == null) {
            throw new IllegalArgumentException("Node identifier may not be null.");
        } else if (StringUtils.isBlank(httpMethod)) {
            throw new IllegalArgumentException("Http method may not be null or empty.");
        } else if (requestUri == null) {
            throw new IllegalArgumentException("Request URI may not be null.");
        } else if (response == null) {
            throw new IllegalArgumentException("ClientResponse may not be null.");
        }
        this.nodeId = nodeId;
        this.httpMethod = httpMethod;
        this.requestUri = requestUri;
        this.response = response;
        this.throwable = null;
        this.updatedEntity = null;
        this.requestDurationNanos = requestDurationNanos;
        this.requestId = requestId;
    }

    public NodeResponse(final NodeIdentifier nodeId, final String httpMethod, final URI requestUri, final Throwable throwable) {
        if (nodeId == null) {
            throw new IllegalArgumentException("Node identifier may not be null.");
        } else if (StringUtils.isBlank(httpMethod)) {
            throw new IllegalArgumentException("Http method may not be null or empty.");
        } else if (requestUri == null) {
            throw new IllegalArgumentException("Request URI may not be null.");
        } else if (throwable == null) {
            throw new IllegalArgumentException("Throwable may not be null.");
        }
        this.nodeId = nodeId;
        this.httpMethod = httpMethod;
        this.requestUri = requestUri;
        this.response = null;
        this.throwable = throwable;
        this.updatedEntity = null;
        this.requestDurationNanos = -1L;
        this.requestId = null;
    }

    public NodeResponse(final NodeResponse example, final Entity updatedEntity) {
        Objects.requireNonNull(example, "NodeResponse cannot be null");
        Objects.requireNonNull(updatedEntity, "UpdatedEntity cannot be null");

        this.nodeId = example.nodeId;
        this.httpMethod = example.httpMethod;
        this.requestUri = example.requestUri;
        this.response = example.response;
        this.throwable = example.throwable;
        this.updatedEntity = updatedEntity;
        this.requestDurationNanos = example.requestDurationNanos;
        this.requestId = null;
    }

    public NodeIdentifier getNodeId() {
        return nodeId;
    }

    public String getHttpMethod() {
        return httpMethod;
    }

    public URI getRequestUri() {
        return requestUri;
    }

    public int getStatus() {
        if (hasThrowable()) {
            /*
             * since there is a throwable, there is no client input stream to
             * worry about maintaining, so we can call getResponse() method
             */
            return getResponse().getStatus();
        } else {
            /*
             * use client response's status instead of calling getResponse().getStatus()
             * so that we don't read the client's input stream as part of creating
             * the response in the getResponse() method
             */
            return response.getStatus();
        }
    }

    public boolean is2xx() {
        final int statusCode = getStatus();
        return (200 <= statusCode && statusCode <= 299);
    }

    public boolean is5xx() {
        final int statusCode = getStatus();
        return (500 <= statusCode && statusCode <= 599);
    }

    public synchronized void bufferResponse() {
        try {
            response.bufferEntity();
        } catch (final ProcessingException e) {
            this.throwable = e;
        }
    }

    public synchronized InputStream getInputStream() {
        return response.readEntity(InputStream.class);
    }

    public Response getClientResponse() {
        return response;
    }

    public Entity getUpdatedEntity() {
        return updatedEntity;
    }

    public Response getResponse() {
        // if the response encapsulates a throwable, then the input stream is never read and the below warning is irrelevant
        if (hasCreatedResponse && !hasThrowable()) {
            logger.warn("ClientResponse's input stream has already been read.  The created response will not contain this data.");
        }
        hasCreatedResponse = true;
        return createResponse();
    }

    public Throwable getThrowable() {
        return throwable;
    }

    public boolean hasThrowable() {
        return getThrowable() != null;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final NodeResponse other = (NodeResponse) obj;
        if (this.nodeId != other.nodeId && (this.nodeId == null || !this.nodeId.equals(other.nodeId))) {
            return false;
        }
        return true;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 13 * hash + (this.nodeId != null ? this.nodeId.hashCode() : 0);
        return hash;
    }

    public long getRequestDuration(final TimeUnit timeUnit) {
        return timeUnit.convert(requestDurationNanos, TimeUnit.NANOSECONDS);
    }

    public String getRequestId() {
        return requestId;
    }

    private Response createResponse() {

        // if no client response was created, then generate a 500 response
        if (hasThrowable()) {
            return Response.status(Status.INTERNAL_SERVER_ERROR).entity(getThrowable().toString()).build();
        }

        // set the status
        final ResponseBuilder responseBuilder = Response.status(response.getStatus());

        // set the headers
        for (final String key : response.getHeaders().keySet()) {
            final List<Object> values = response.getHeaders().get(key);
            for (final Object value : values) {
                if (key.equalsIgnoreCase("transfer-encoding") || key.equalsIgnoreCase("content-length")) {
                    /*
                     * do not copy the transfer-encoding header (i.e., chunked encoding) or
                     * the content-length. Let the outgoing response builder determine it.
                     */
                    continue;
                }

                responseBuilder.header(key, value);
            }
        }

        // head requests must not have a message-body in the response
        if (!HttpMethod.HEAD.equalsIgnoreCase(httpMethod)) {
            // set the entity
            if (updatedEntity == null) {
                responseBuilder.entity((StreamingOutput) output -> IOUtils.copy(getInputStream(), output));
            } else {
                responseBuilder.entity(updatedEntity);
            }
        }

        return responseBuilder.build();
    }

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder();
        sb.append("NodeResponse[nodeUri=").append(nodeId.getApiAddress()).append(":").append(nodeId.getApiPort()).append(",")
            .append("method=").append(httpMethod)
            .append(",URI=").append(requestUri)
            .append(",ResponseCode=").append(getStatus())
            .append(",Duration=").append(TimeUnit.MILLISECONDS.convert(requestDurationNanos, TimeUnit.NANOSECONDS)).append(" ms]");
        return sb.toString();
    }
}
