/*
 * Copyright 2013 Robert von Burg <eitch@eitchnet.ch>
 *
 * 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 li.strolch.fileserver;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.text.MessageFormat;

import li.strolch.utils.helper.FileHelper;
import li.strolch.utils.helper.StringHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class handles remote requests of clients to upload or download a file. Uploading a file is done by calling
 * {@link #handleFilePart(FilePart)} and the downloading a file is done by calling {@link #requestFile(FilePart)}
 *
 * @author Robert von Burg &lt;eitch@eitchnet.ch&gt;
 */
public class FileHandler {

	private static final Logger logger = LoggerFactory.getLogger(FileHandler.class);

	/**
	 * DEF_PART_SIZE = default part size which is set to 1048576 bytes (1 MiB)
	 */
	public static final int MAX_PART_SIZE = 1048576;

	private String basePath;
	private boolean verbose;

	/**
	 *
	 */
	public FileHandler(String basePath, boolean verbose) {

		File basePathF = new File(basePath);
		if (!basePathF.exists()) {
			String msg = MessageFormat
					.format("Base Path does not exist {0}", basePathF.getAbsolutePath()); //$NON-NLS-1$
			throw new RuntimeException(msg);
		}
		if (!basePathF.canWrite()) {
			String msg = MessageFormat
					.format("Can not write to base path {0}", basePathF.getAbsolutePath()); //$NON-NLS-1$
			throw new RuntimeException(msg);
		}

		this.verbose = verbose;
		this.basePath = basePath;
	}

	/**
	 * Method which a client can request part of a file. The server will fill the given {@link FilePart} with a byte
	 * array of the file, with bytes from the file, respecting the desired offset. It is up to the client to call this
	 * method multiple times for the entire file. It is a decision of the concrete implementation how much data is
	 * returned in each part, the client may pass a request, but this is not definitive
	 *
	 * @param filePart
	 * 		the part of the file
	 */
	public FilePart requestFile(FilePart filePart) {

		// validate file name is legal
		String fileName = filePart.getFileName();
		validateFileName(fileName);

		// validate type is legal
		String fileType = filePart.getFileType();
		validateFileType(fileType);

		// evaluate the path where the file should reside
		String fileTypePath = this.basePath + "/" + fileType; //$NON-NLS-1$
		File file = new File(fileTypePath, filePart.getFileName());

		// now evaluate the file exists
		String fileNotFoundMsg = "The file {0} could not be found in the location for files of type {1}"; //$NON-NLS-1$
		if (!file.canRead()) {
			String msg = fileNotFoundMsg;
			msg = MessageFormat.format(msg, fileName, fileType);
			throw new RuntimeException(msg);
		}

		// if this is the start of the file, then prepare the file part
		long fileSize = file.length();
		if (filePart.getPartOffset() == 0) {

			// set the file length
			filePart.setFileLength(fileSize);

			// set the SHA256 of the file
			filePart.setFileHash(StringHelper.toHexString(FileHelper.hashFileSha256(file)));
		}

		// variables defining the part of the file we're going to return
		long requestOffset = filePart.getPartOffset();
		int requestSize = filePart.getPartLength();
		if (requestSize > FileHandler.MAX_PART_SIZE) {
			String msg = "The requested part size {0} is greater than the allowed {1}"; //$NON-NLS-1$
			msg = MessageFormat.format(msg, requestSize, MAX_PART_SIZE);
			throw new RuntimeException(msg);
		}

		// validate lengths and offsets
		if (filePart.getFileLength() != fileSize) {
			String msg = "The part request has a file size {0}, but the file is actually {1}"; //$NON-NLS-1$
			msg = MessageFormat.format(msg, filePart.getFileLength(), fileSize);
			throw new RuntimeException(msg);
		} else if (requestOffset > fileSize) {
			String msg = "The requested file part offset {0} is greater than the size of the file {1}"; //$NON-NLS-1$
			msg = MessageFormat.format(msg, requestOffset, fileSize);
			throw new RuntimeException(msg);
		}
		// Otherwise make sure the offset + request length is not larger than the actual file size. 
		// If it is then this is the end part
		else if (requestOffset + requestSize >= fileSize) {

			long remaining = fileSize - requestOffset;

			// update request size to last part of file
			long l = Math.min(requestSize, remaining);

			// this is the size of the array we want to return
			requestSize = (int) l;
			filePart.setPartLength(requestSize);
			filePart.setLastPart(true);
		}

		// now read the part of the file and set it as bytes for the file part
		try (InputStream fin = Files.newInputStream(file.toPath())) {

			// position the stream
			long skip = fin.skip(requestOffset);
			if (skip != requestOffset) {
				String msg = MessageFormat
						.format("Asked to skip {0} but only skipped {1}", requestOffset, skip); //$NON-NLS-1$
				throw new IOException(msg);
			}

			// read the data
			byte[] bytes = new byte[requestSize];
			int read = fin.read(bytes);
			if (read != requestSize) {
				String msg = MessageFormat
						.format("Asked to read {0} but only read {1}", requestSize, read); //$NON-NLS-1$
				throw new IOException(msg);
			}

			// set the return result
			filePart.setPartBytes(bytes);

		} catch (FileNotFoundException e) {
			String msg = MessageFormat.format(fileNotFoundMsg, fileName, fileType);
			throw new RuntimeException(msg);
		} catch (IOException e) {
			String msg = "There was an error while reading from the file {0}"; //$NON-NLS-1$
			msg = MessageFormat.format(msg, fileName);
			throw new RuntimeException(msg);
		}

		// we are returning the same object as the user gave us, just modified
		if (this.verbose) {
			String msg = "Read {0} for file {1}/{2}"; //$NON-NLS-1$
			String fileSizeS = FileHelper.humanizeFileSize(filePart.getPartBytes().length);
			msg = MessageFormat.format(msg, fileSizeS, fileType, fileName);
			logger.info(msg);
		}
		return filePart;
	}

	/**
	 * Method with which a client can push parts of files to the server. It is up to the client to send as many parts as
	 * needed, the server will write the parts to the associated file
	 *
	 * @param filePart
	 * 		the part of the file
	 */
	public void handleFilePart(FilePart filePart) {

		// validate file name is legal
		String fileName = filePart.getFileName();
		validateFileName(fileName);

		// validate type is legal
		String fileType = filePart.getFileType();
		validateFileType(fileType);

		// evaluate the path where the file should reside
		String fileTypePath = this.basePath + "/" + fileType; //$NON-NLS-1$
		File dstFile = new File(fileTypePath, filePart.getFileName());

		// if the file already exists, then this may not be a start part
		if (filePart.getPartOffset() == 0 && dstFile.exists()) {
			String msg = "The file {0} already exist for type {1}"; //$NON-NLS-1$
			msg = MessageFormat.format(msg, fileName, fileType);
			throw new RuntimeException(msg);
		}

		// write the part
		FileHelper.appendFilePart(dstFile, filePart.getPartBytes());

		// if this is the last part, then validate the hashes
		if (filePart.isLastPart()) {
			String dstFileHash = StringHelper.toHexString(FileHelper.hashFileSha256(dstFile));
			if (!dstFileHash.equals(filePart.getFileHash())) {
				String msg = "Uploading the file {0} failed because the hashes don''t match. Expected: {1} / Actual: {2}"; //$NON-NLS-1$
				msg = MessageFormat.format(msg, filePart.getFileName(), filePart.getFileHash(), dstFileHash);
				throw new RuntimeException(msg);
			}
		}

		if (this.verbose) {
			String msg;
			if (filePart.isLastPart())
				msg = "Wrote {0} for part of file {1}/{2}"; //$NON-NLS-1$
			else
				msg = "Wrote {0} for last part of file {1}/{2}"; //$NON-NLS-1$
			String fileSizeS = FileHelper.humanizeFileSize(filePart.getPartBytes().length);
			msg = MessageFormat.format(msg, fileSizeS, fileType, fileName);
			logger.info(msg);
		}
	}

	/**
	 * Method with which a client can delete files from the server. It only deletes single files if they exist
	 *
	 * @param fileDeletion
	 * 		the {@link FileDeletion} defining the deletion request
	 *
	 * @return true if the file was deleted, false if the file did not exist
	 */
	public boolean deleteFile(FileDeletion fileDeletion) {

		// validate file name is legal
		String fileName = fileDeletion.getFileName();
		validateFileName(fileName);

		// validate type is legal
		String fileType = fileDeletion.getFileType();
		validateFileType(fileType);

		// evaluate the path where the file should reside
		String fileTypePath = this.basePath + "/" + fileType; //$NON-NLS-1$
		File fileToDelete = new File(fileTypePath, fileDeletion.getFileName());

		// delete the file
		boolean deletedFile = FileHelper.deleteFiles(new File[] { fileToDelete }, true);

		String msg;
		if (deletedFile)
			msg = "Deleted file {1}/{2}"; //$NON-NLS-1$
		else
			msg = "Failed to delete file {1}/{2}"; //$NON-NLS-1$
		msg = MessageFormat.format(msg, fileType, fileName);
		logger.info(msg);
		return deletedFile;
	}

	/**
	 * Validates that the file name is legal, i.e. not empty or contains references up the tree
	 *
	 * @param fileName the name to validate
	 */
	private void validateFileName(String fileName) {

		if (fileName == null || fileName.isEmpty()) {
			throw new RuntimeException(
					"The file name was not given! Can not find a file without a name!"); //$NON-NLS-1$
		} else if (fileName.contains("/")) { //$NON-NLS-1$
			String msg = "The given file name contains illegal characters. The file name may not contain slashes!"; //$NON-NLS-1$
			throw new RuntimeException(msg);
		}
	}

	/**
	 * Validates that the file type is legal, i.e. not empty or contains references up the tree
	 *
	 * @param fileType the file type to validate
	 */
	private void validateFileType(String fileType) {
		if (fileType == null || fileType.isEmpty()) {
			throw new RuntimeException(
					"The file type was not given! Can not find a file without a type!"); //$NON-NLS-1$
		} else if (fileType.contains("/")) { //$NON-NLS-1$
			String msg = "The given file type contains illegal characters. The file type may not contain slashes!"; //$NON-NLS-1$
			throw new RuntimeException(msg);
		}
	}
}
