/*
 * 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.jackrabbit.oak.plugins.blob.datastore.directaccess;

import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Optional;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.apache.jackrabbit.oak.spi.blob.AbstractSharedBackend;
import org.apache.jackrabbit.oak.spi.blob.data.DataIdentifier;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Represents an upload token returned by
 * {@link DataRecordAccessProvider#initiateDataRecordUpload(long, int)} and
 * used in subsequent calls to {@link
 * DataRecordAccessProvider#completeDataRecordUpload(String)}.  This class
 * handles creation, signing, and parsing of the token and uses a provided
 * secret key to sign the contents of the token and to validate contents of
 * tokens.
 */
public class DataRecordUploadToken {
    private static Logger LOG = LoggerFactory.getLogger(DataRecordUploadToken.class);

    private String blobId;
    private Optional<String> uploadId;

    /**
     * Create an upload token from the provided {@code blobId} and {@code
     * uploadId}.  At creation time the token is not encoded or signed; to do
     * that call {@link #getEncodedToken(byte[])} after creating the token.
     *
     * @param blobId The blob ID, usually a {@link
     *        DataIdentifier}.
     * @param uploadId A free-form string used to identify this upload.  This
     *        may be provided by the service provider; if not a free-form
     *        upload ID generated by the implementation will suffice.  May be
     *        {@code null} if no upload ID is available.  However, some service
     *        providers will require an upload ID to complete the upload so be
     *        sure to check whether the service provider API provides one and
     *        use that if it is available.
     */
    public DataRecordUploadToken(@NotNull String blobId, @Nullable String uploadId) {
        this.blobId = blobId;
        this.uploadId = Optional.ofNullable(uploadId);
    }

    /**
     * Create an upload token instance from the provided encoded token string,
     * using the provided secret key to verify the string.  The encoded token
     * string should have been created originally by a prior call to {@link
     * #getEncodedToken(byte[])}.
     * <p>
     * This method will parse and validate the contents of the provided encoded
     * token string.  An instance of this class is returned if the parsing and
     * validation is successful.
     * <p>
     * A secret key is required to verify the encoded token.  You are strongly
     * encouraged to use the secret key used by the data store backend
     * implementation.  This key can be obtained by calling {@link
     * AbstractSharedBackend#getOrCreateReferenceKey()}.
     *
     * @param encoded The encoded, signed token string.
     * @param secret The secret key to be used to verify the contents of the
     *         token string.
     * @return A new instance containing the parsed upload token propreties.
     * @throws IllegalArgumentException if the token string cannot be parsed or
     *         if validation fails.
     */
    public static DataRecordUploadToken fromEncodedToken(@NotNull String encoded, @NotNull byte[] secret)
            throws IllegalArgumentException {
        final String[] parts = encoded.split("#", 2);
        if (parts.length < 2) {
            throw new IllegalArgumentException("Invalid upload token");
        }

        final String toBeDecoded = parts[0];
        final String expectedSig = parts[1];
        final String actualSig = getSignedString(toBeDecoded, secret);
        if (!expectedSig.equals(actualSig)) {
            throw new IllegalArgumentException("Invalid upload token");
        }

        String decoded = decodeBase64(toBeDecoded);
        String decodedParts[] = decoded.split("#");
        if (decodedParts.length < 2) {
            throw new IllegalArgumentException("Invalid upload token");
        }

        return new DataRecordUploadToken(decodedParts[0], decodedParts.length > 2 ? decodedParts[2] : null);
    }

    /**
     * Generate an encoded, signed token string from this instance.  The
     * resulting token can later be parsed and validated by {@link
     * #fromEncodedToken(String, byte[])}.
     * <p>
     * A secret key is required to generate the encoded token.  You are strongly
     * encouraged to use the secret key used by the data store backend
     * implementation.  This key can be obtained by calling {@link
     * AbstractSharedBackend#getOrCreateReferenceKey()}.
     *
     * @param secret The secret key used to sign the contents of the token.
     * @return An encoded token string that can later be used to uniquely and
     *         securely identify an upload.
     */
    public String getEncodedToken(@NotNull byte[] secret) {
        String now = Instant.now().toString();
        String toBeEncoded = uploadId.isPresent() ?
                String.join("#", blobId, now, uploadId.get()) :
                String.join("#", blobId, now);
        String toBeSigned = encodeBase64(toBeEncoded);

        String sig = getSignedString(toBeSigned, secret);
        return sig != null ? toBeSigned + "#" + sig : toBeSigned;
    }

    /** Returns the base64 encoded HMAC signature */
    private static String getSignedString(String toBeSigned, byte[] secret) {
        try {
            final String algorithm = "HmacSHA1";
            Mac mac = Mac.getInstance(algorithm);
            mac.init(new SecretKeySpec(secret, algorithm));
            byte[] hash = mac.doFinal(toBeSigned.getBytes(StandardCharsets.UTF_8));
            return encodeBase64(hash);
        }
        catch (NoSuchAlgorithmException | InvalidKeyException e) {
            LOG.warn("Could not sign upload token", e);
        }
        return null;
    }

    private static String encodeBase64(String string) {
        return Base64.encodeBase64String(string.getBytes(StandardCharsets.UTF_8));
    }

    private static String encodeBase64(byte[] bytes) {
        return Base64.encodeBase64String(bytes);
    }

    private static String decodeBase64(String encodedString) {
        return new String(Base64.decodeBase64(encodedString), StandardCharsets.UTF_8);
    }

    /**
     * Returns the blob ID of this instance.
     *
     * @return The blob ID.
     */
    public String getBlobId() {
        return blobId;
    }

    /**
     * Returns the upload ID of this instance.
     *
     * @return The upload ID.
     */
    public Optional<String> getUploadId() {
        return uploadId;
    }
}
