package com.nifty.cloud.ncs.service;

import java.io.File;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;

import java.security.NoSuchAlgorithmException;

import javax.crypto.Cipher;

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.nifty.cloud.ncs.NiftyCloudClientException;
import com.nifty.cloud.ncs.NiftyCloudFileInputStream;
import com.nifty.cloud.ncs.NiftyCloudServiceException;
import com.nifty.cloud.ncs.auth.Credentials;
import com.nifty.cloud.ncs.http.ClientProperties;
import com.nifty.cloud.ncs.service.acl.Policy;
import com.nifty.cloud.ncs.encrypt.CryptoConfiguration;
import com.nifty.cloud.ncs.encrypt.CryptoStorageMode;
import com.nifty.cloud.ncs.encrypt.EncryptionConstants;
import com.nifty.cloud.ncs.encrypt.EncryptionInstruction;
import com.nifty.cloud.ncs.encrypt.EncryptionKeyMaterials;
import com.nifty.cloud.ncs.encrypt.EncryptionUtils;


/**
 * データを暗号化して転送するためのニフクラストレージクライアントです。
 */
public class StorageEncryptionClient extends StorageClient
{
    private EncryptionKeyMaterials encryptionMaterials;
    private CryptoConfiguration cryptoConfig;

    private static final int SC_NOT_FOUND = 404;

    private static Log log = LogFactory.getLog(StorageEncryptionClient.class);

    /**
     * アクセスキー/シークレットキー、暗号鍵情報を指定して新しいStorageEncryptionClientを構築します。
     *
     * @param credentials
     *           アクセスキー/シークレットキー
     * @param encryptionMaterials
     *           暗号化に使用する鍵情報
     */
    public StorageEncryptionClient(Credentials credentials, EncryptionKeyMaterials encryptionMaterials) {
        this(credentials, new ClientProperties(), encryptionMaterials, new CryptoConfiguration());
    }

    /**
     * アクセスキー/シークレットキー、暗号鍵情報、クライアント設定を指定して新しいStorageEncryptionClientを構築します。
     *
     * @param credentials
     *           アクセスキー/シークレットキー情報
     * @param clientProperties
     *           クライアント設定
     * @param encryptionMaterials
     *           暗号化に使用する鍵情報
     */
    public StorageEncryptionClient(Credentials credentials, ClientProperties clientProperties, EncryptionKeyMaterials encryptionMaterials) {
        this(credentials, clientProperties, encryptionMaterials, new CryptoConfiguration());
    }

    /**
     * アクセスキー/シークレットキー、暗号鍵情報、暗号化設定を指定して新しいStorageEncryptionClientを構築します。
     *
     * @param credentials
     *           アクセスキー/シークレットキー情報
     * @param encryptionMaterials
     *           暗号化に使用する鍵情報
     * @param cryptoConfig
     *           暗号化クライアント設定
     */
    public StorageEncryptionClient(Credentials credentials, EncryptionKeyMaterials encryptionMaterials, CryptoConfiguration cryptoConfig) {
        this(credentials, new ClientProperties(), encryptionMaterials, cryptoConfig);
    }

    /**
     * アクセスキー/シークレットキー、暗号鍵情報、クライアント設定、暗号化設定を指定して新しいStorageEncryptionClientを構築します。
     *
     * @param credentials
     *           アクセスキー/シークレットキー情報
     * @param clientProperties
     *           クライアント設定
     * @param encryptionMaterials
     *           暗号化に使用する鍵情報
     * @param cryptoConfig
     *           暗号化クライアント設定
     */
    public StorageEncryptionClient(Credentials credentials, ClientProperties clientProperties,
    		EncryptionKeyMaterials encryptionMaterials, CryptoConfiguration cryptoConfig)
    {
        super(credentials, clientProperties);

        if (encryptionMaterials == null) {
        	throw (new NiftyCloudClientException("EncryptionMaterials parameter must not be null."));
        }
        if (cryptoConfig == null) {
        	throw (new NiftyCloudClientException("CryptoConfiguration parameter must not be null."));
        }

        this.encryptionMaterials = encryptionMaterials;
        this.cryptoConfig = cryptoConfig;
		StorageHeadersImpl.setPrefix(credentials.getExtendedHeaderPrefix());
    }

    /**
     *{@inheritDoc}
     *
     * <p>
     * <b>NOTE:</b>
     * {@link CryptoStorageMode}を<code>InstructionFile</code>に設定している場合、「オブジェクト名.instruction」
     * という名前のオブジェクトが別途作成されます。
     * </p>
     */
    @Override
    public String putObject(NcsObject object, File file, Policy policy)
    		throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	if (file == null) {
			throw (new NiftyCloudClientException("The argument of file must be specified."));
		}

    	// ディレクトリの場合は通常のディレクトリ作成を行う
    	if (file.isDirectory()) {
    		return (putObject(object, policy));
    	}

		// Mimetypeの取得
		String contentType = Mimetypes.getInstance().getMimetype(file);

		// Content-Length設定
		object.setContentLength(file.length());

    	NiftyCloudFileInputStream input = null;
		try {
			input = new NiftyCloudFileInputStream(file);
		} catch (Exception e) {
			throw (new NiftyCloudClientException(e.getMessage()));
		}

    	return (putObject(object, input, contentType, policy));
    }

    /**
     *{@inheritDoc}
     *
     * <p>
     * <b>NOTE:</b>
     * {@link CryptoStorageMode}を<code>InstructionFile</code>に設定している場合、「オブジェクト名.instruction」
     * という名前のオブジェクトが別途作成されます。
     * </p>
     */
    public String putObject(NcsObject object, InputStream input, String contentType, Policy policy)
    		throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	if (object == null) {
			throw (new NiftyCloudClientException("NcsObject must not be null."));
		}

		if (input == null) {
			throw (new NiftyCloudClientException("InputStream must not be null."));
		}

		if (cryptoConfig.getStorageMode() == CryptoStorageMode.InstructionFile) {
			// Instructionファイル利用
    		return (putObjectUsingInstructionFile(object, input, contentType, policy));
		}
		else {
			// メタデータ利用
			return (putObjectUsingMetadata(object, input, contentType, policy));
		}
    }


    @Override
    public NcsObject getObject(String bucketName, String objectName, long[] range)
    		throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	// 復号化のための取得範囲調整
    	long[] adjustedCryptoRange = EncryptionUtils.getAdjustedCryptoRange(range);

    	// オブジェクト取得
    	NcsObject responseObject = super.getObject(bucketName, objectName, adjustedCryptoRange);

    	if (responseObject == null) {
    		return (null);
    	}

    	// オブジェクト復号化
    	NcsObject decryptedObject = decryptObject(responseObject);

    	// データの範囲調整
    	return (EncryptionUtils.getRangeAdjustedObject(decryptedObject, range));
    }

    @Override
    public void getObject(String bucketName, String objectName, long[] range, String fileName) throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	// オブジェクト取得
    	NcsObject responseObject = getObject(bucketName, objectName, range);

    	if (responseObject == null) {
    		return;
    	}

    	// ファイル出力
    	outputObjectToFile(responseObject, fileName);
    }

    @Override
    public void getObject(NcsObject object, String fileName)
    {
    	// オブジェクト取得
    	NcsObject responseObject = getObject(object, (long[])null);

    	if (responseObject == null) {
    		return;
    	}

    	// オブジェクト復号化
    	NcsObject decryptedObject = decryptObject(responseObject);

    	// ファイル出力
    	outputObjectToFile(decryptedObject, fileName);
    }

	@Override
	public void deleteObject(String bucketName, String objectName)
			throws NiftyCloudClientException, NiftyCloudServiceException
	{
		// オブジェクト削除
		super.deleteObject(bucketName, objectName);

		try {
			// Instructionファイルを削除
			super.deleteObject(bucketName, objectName + EncryptionConstants.INSTRUCTION_SUFFIX);

		} catch (StorageException e) {
			if (e.getStatusCode() != SC_NOT_FOUND) {
				throw (e);
			}
		}
	}

    /**
     * オブジェクトを作成します。
     * Instructionファイルに暗号化情報を保持します。
     *
     * @param object
     * @param input
     * @param contentType
     * @param policy
     * @return
     * @throws NiftyCloudClientException
     * @throws NiftyCloudServiceException
     */
    private String putObjectUsingInstructionFile(NcsObject object, InputStream input, String contentType, Policy policy)
    throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	// 暗号化情報生成
    	EncryptionInstruction instruction = EncryptionUtils.generateInstruction(this.encryptionMaterials, this.cryptoConfig.getCryptoProvider());

    	Cipher symmetricCipher = instruction.getSymmetricCipher();

    	// 暗号化のためのデータサイズ調整
    	long cryptoContentLength = EncryptionUtils.calculateCryptoContentLength(symmetricCipher, object);
    	if (cryptoContentLength > 0) {
    		object.setContentLength(cryptoContentLength);
    	}

		InputStream encryptedInputStream = null;
		String contentSHA256 = "";
		try {
			// オブジェクトデータの暗号化
			InputStream tmpEncryptedStream = EncryptionUtils.getEncryptedInputStream(input, symmetricCipher);

			ByteArrayOutputStream streamToBeCopied = new ByteArrayOutputStream();
			byte[] buffer = new byte[16 * 1024];
			int bytesRead = -1;
			while ((bytesRead = tmpEncryptedStream.read(buffer)) > -1) {
				streamToBeCopied.write(buffer, 0, bytesRead);
			}
			tmpEncryptedStream.close();
			streamToBeCopied.flush();

			InputStream encryptedStreamForHash = new ByteArrayInputStream(streamToBeCopied.toByteArray());
			byte[] sha256Hash = computeSHA256Hash(encryptedStreamForHash);
			encryptedStreamForHash.close();
			contentSHA256 = Hex.encodeHexString(sha256Hash);

			encryptedInputStream = new ByteArrayInputStream(streamToBeCopied.toByteArray());

			streamToBeCopied.close();
		} catch (IOException e) {
			throw (new NiftyCloudClientException("Can't duplicate stream: " + e.getMessage(), e));
		} catch (NoSuchAlgorithmException e) {
			throw (new NiftyCloudClientException("Can't calculate SHA256 hash: " + e.getMessage(), e));
		}

		// オブジェクト作成
    	String result = super.putObject(object, encryptedInputStream, contentSHA256, contentType, policy);

    	// Instructionファイルのアップロード
    	NcsObject instructionFile = EncryptionUtils.createInstructionRequestObject(object.getBucketName(), object.getObjectName(), instruction);
		String instructionFileSHA256 = Hex.encodeHexString(EncryptionUtils.computeInstructionFileSHA256(instruction));

    	super.putObject(instructionFile, instructionFile.getContent(), instructionFileSHA256, contentType, policy);

    	return (result);
    }

    /**
     * オブジェクトを作成します。
     * オブジェクトのメタデータに暗号化情報を保持します。
     *
     * @param object
     * @param input
     * @param contentType
     * @param policy
     * @return
     * @throws NiftyCloudClientException
     * @throws NiftyCloudServiceException
     */
    private String putObjectUsingMetadata(NcsObject object, InputStream input, String contentType, Policy policy)
    throws NiftyCloudClientException, NiftyCloudServiceException
    {
    	// 暗号化情報生成
    	EncryptionInstruction instruction = EncryptionUtils.generateInstruction(this.encryptionMaterials, this.cryptoConfig.getCryptoProvider());

    	Cipher symmetricCipher = instruction.getSymmetricCipher();

    	// 暗号化のためのデータサイズ調整
    	long cryptoContentLength = EncryptionUtils.calculateCryptoContentLength(symmetricCipher, object);
    	if (cryptoContentLength > 0)  {
    		object.setContentLength(cryptoContentLength);
    	}

		InputStream encryptedInputStream = null;
		String contentSHA256 = "";
		try {
			// オブジェクトデータの暗号化
			InputStream tmpEncryptedStream = EncryptionUtils.getEncryptedInputStream(input, symmetricCipher);

			ByteArrayOutputStream streamToBeCopied = new ByteArrayOutputStream();
			byte[] buffer = new byte[16 * 1024];
			int bytesRead = -1;
			while ((bytesRead = tmpEncryptedStream.read(buffer)) > -1) {
				streamToBeCopied.write(buffer, 0, bytesRead);
			}
			tmpEncryptedStream.close();
			streamToBeCopied.flush();

			InputStream encryptedStreamForHash = new ByteArrayInputStream(streamToBeCopied.toByteArray());
			byte[] sha256Hash = computeSHA256Hash(encryptedStreamForHash);
			encryptedStreamForHash.close();
			contentSHA256 = Hex.encodeHexString(sha256Hash);

			encryptedInputStream = new ByteArrayInputStream(streamToBeCopied.toByteArray());

			streamToBeCopied.close();
		} catch (IOException e) {
			throw (new NiftyCloudClientException("Can't duplicate stream: " + e.getMessage(), e));
		} catch (NoSuchAlgorithmException e) {
			throw (new NiftyCloudClientException("Can't calculate SHA256 hash: " + e.getMessage(), e));
		}

    	// 暗号化情報をメタデータに設定
    	EncryptionUtils.updateMetadataWithEncryptionInstruction( object, instruction );

    	// オブジェクト作成
    	return (super.putObject(object, encryptedInputStream, contentSHA256, contentType, policy));
    }

    /**
     * オブジェクトデータを復号化します。
     *
     * @param object
     * @return
     */
    private NcsObject decryptObject(NcsObject object)
    {
    	if (Mimetypes.MIMETYPE_DIRECTORY.equals(object.getMetadata().get(StorageHeaders.CONTENT_TYPE))) {
    		return (object);
    	}

    	NcsObject returnObject;

    	// 暗号化情報をメタデータまたはInstructionファイルから取得
    	if (EncryptionUtils.isEncryptionInfoInMetadata(object)) {
    		returnObject = decryptObjectUsingMetadata(object);

    	} else {
    		NcsObject instructionFile = getInstructionFile(object);

    		if (EncryptionUtils.isEncryptionInfoInInstructionFile(instructionFile)) {
    			returnObject = decryptObjectUsingInstructionFile(object, instructionFile);

    		} else {
    			/*
    			 * オブジェクトに暗号化情報が含まれない場合は、通常オブジェクトとみなし、
    			 * 復号化せずに結果を返却する
    			 */
    			log.warn(String.format("Unable to detect encryption information for object '%s' in bucket '%s'. " +
    					"Returning object without decryption.",
    					object.getObjectName(), object.getBucketName()));

    			returnObject = object;
    		}
    	}

    	return (returnObject);
    }

    /**
     * メタデータに含まれる暗号化情報を使用して、オブジェクトデータを復号化します。
     *
     * @param object
     * @return
     */
    private NcsObject decryptObjectUsingMetadata(NcsObject object)
    {
    	// メタデータから暗号化情報取得
    	EncryptionInstruction instruction =
    		EncryptionUtils.buildInstructionFromMetadata( object, this.encryptionMaterials, this.cryptoConfig.getCryptoProvider() );

    	// オブジェクトデータの復号化
    	return (EncryptionUtils.decryptObjectUsingInstruction(object, instruction));
    }

    /**
     * Instructionファイルに含まれる暗号化情報を使用して、オブジェクトデータを復号化します。
     *
     * @param object
     * @param instructionFile
     * @return
     */
    private NcsObject decryptObjectUsingInstructionFile(NcsObject object, NcsObject instructionFile)
    {
    	// Instructionファイルから暗号化情報取得
    	EncryptionInstruction instruction = EncryptionUtils.buildInstructionFromInstructionFile(instructionFile, this.encryptionMaterials, this.cryptoConfig.getCryptoProvider());

    	// オブジェクトデータの復号化
    	return (EncryptionUtils.decryptObjectUsingInstruction(object, instruction));
    }

    /**
     * Instructionファイルをダウンロードします。
     *
     * @param object
     * @return
     */
    private NcsObject getInstructionFile(NcsObject object)
    {
    	try {
    		// Instructionファイルのダウンロード
    		return (super.getObject(object.getBucketName(), object.getObjectName() + EncryptionConstants.INSTRUCTION_SUFFIX, null));

    	} catch (NiftyCloudServiceException e) {
    		log.debug("Unable to retrieve instruction file : " + e.getMessage());

    		return (null);
    	}
    }
}
