/*
 * Copyright 2015 gitblit.com.
 *
 * 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 com.gitblit.manager;

import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.models.FilestoreModel;
import com.gitblit.models.FilestoreModel.Status;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.JsonUtils.GmtDateTypeAdapter;
import com.google.gson.ExclusionStrategy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.google.inject.Inject;
import com.google.inject.Singleton;

/**
 * FilestoreManager handles files uploaded via:
 * 	+ git-lfs
 *  + ticket attachment (TBD)
 *
 * Files are stored using their SHA256 hash (as per git-lfs)
 * If the same file is uploaded through different repositories no additional space is used
 * Access is controlled through the current repository permissions.
 *
 * TODO: Identify what and how the actual BLOBs should work with federation
 *
 * @author Paul Martin
 *
 */
@Singleton
public class FilestoreManager implements IFilestoreManager {

	private final Logger logger = LoggerFactory.getLogger(getClass());

	private final IRuntimeManager runtimeManager;
	
	private final IRepositoryManager repositoryManager;

	private final IStoredSettings settings;

	public static final int UNDEFINED_SIZE = -1;

	private static final String METAFILE = "filestore.json";

	private static final String METAFILE_TMP = "filestore.json.tmp";

	protected static final Type METAFILE_TYPE = new TypeToken<Collection<FilestoreModel>>() {}.getType();

	private Map<String, FilestoreModel > fileCache = new ConcurrentHashMap<String, FilestoreModel>();


	@Inject
	FilestoreManager(
			IRuntimeManager runtimeManager,
			IRepositoryManager repositoryManager) {
		this.runtimeManager = runtimeManager;
		this.repositoryManager = repositoryManager;
		this.settings = runtimeManager.getSettings();
	}

	@Override
	public IManager start() {

		// Try to load any existing metadata
		File dir = getStorageFolder();
		dir.mkdirs();
		File metadata = new File(dir, METAFILE);

		if (metadata.exists()) {
			Collection<FilestoreModel> items = null;

			Gson gson = gson();
			try (FileReader file = new FileReader(metadata)) {
				items = gson.fromJson(file, METAFILE_TYPE);
				file.close();

			} catch (IOException e) {
				e.printStackTrace();
			}

			for(Iterator<FilestoreModel> itr = items.iterator(); itr.hasNext(); ) {
			    FilestoreModel model = itr.next();
			    fileCache.put(model.oid, model);
			}

			logger.info("Loaded {} items from filestore metadata file", fileCache.size());
		}
		else
		{
			logger.info("No filestore metadata file found");
		}

		return this;
	}

	@Override
	public IManager stop() {
		return this;
	}


	@Override
	public boolean isValidOid(String oid) {
		//NOTE: Assuming SHA256 support only as per git-lfs
		return Pattern.matches("[a-fA-F0-9]{64}", oid);
	}

	@Override
	public FilestoreModel.Status addObject(String oid, long size, UserModel user, RepositoryModel repo) {

		//Handle access control
		if (!user.canPush(repo)) {
			if (user == UserModel.ANONYMOUS) {
				return Status.AuthenticationRequired;
			} else {
				return Status.Error_Unauthorized;
			}
		}

		//Handle object details
		if (!isValidOid(oid)) { return Status.Error_Invalid_Oid; }

		if (fileCache.containsKey(oid)) {
			FilestoreModel item = fileCache.get(oid);

			if (!item.isInErrorState() && (size != UNDEFINED_SIZE) && (item.getSize() != size)) {
				return Status.Error_Size_Mismatch;
			}

			item.addRepository(repo.name);

			if (item.isInErrorState()) {
				item.reset(user, size);
			}
		} else {

			if (size  < 0) {return Status.Error_Invalid_Size; }
			if ((getMaxUploadSize() != UNDEFINED_SIZE) && (size > getMaxUploadSize())) { return Status.Error_Exceeds_Size_Limit; }

			FilestoreModel model = new FilestoreModel(oid, size, user, repo.name);
			fileCache.put(oid, model);
			saveFilestoreModel(model);
		}

		return fileCache.get(oid).getStatus();
	}

	@Override
	public FilestoreModel.Status uploadBlob(String oid, long size, UserModel user, RepositoryModel repo, InputStream streamIn) {

		//Access control and object logic
		Status state = addObject(oid, size, user, repo);

		if (state != Status.Upload_Pending) {
			return state;
		}

		FilestoreModel model = fileCache.get(oid);

		if (!model.actionUpload(user)) {
			return Status.Upload_In_Progress;
		} else {
			long actualSize = 0;
			File file = getStoragePath(oid);

			try {
				file.getParentFile().mkdirs();
				file.createNewFile();

				try (FileOutputStream streamOut = new FileOutputStream(file)) {

					actualSize = IOUtils.copyLarge(streamIn, streamOut);

					streamOut.flush();
					streamOut.close();

					if (model.getSize() != actualSize) {
						model.setStatus(Status.Error_Size_Mismatch, user);

						logger.warn(MessageFormat.format("Failed to upload blob {0} due to size mismatch, expected {1} got {2}",
								oid, model.getSize(), actualSize));
					} else {
						String actualOid = "";

						try (FileInputStream fileForHash = new FileInputStream(file)) {
							actualOid = DigestUtils.sha256Hex(fileForHash);
							fileForHash.close();
						}

						if (oid.equalsIgnoreCase(actualOid)) {
							model.setStatus(Status.Available, user);
						} else {
							model.setStatus(Status.Error_Hash_Mismatch, user);

							logger.warn(MessageFormat.format("Failed to upload blob {0} due to hash mismatch, got {1}", oid, actualOid));
						}
					}
				}
			} catch (Exception e) {

				model.setStatus(Status.Error_Unknown, user);
				logger.warn(MessageFormat.format("Failed to upload blob {0}", oid), e);
			} finally {
				saveFilestoreModel(model);
			}

			if (model.isInErrorState()) {
				file.delete();
				model.removeRepository(repo.name);
			}
		}

		return model.getStatus();
	}

	private FilestoreModel.Status canGetObject(String oid, UserModel user, RepositoryModel repo) {

		//Access Control
		if (!user.canView(repo)) {
			if (user == UserModel.ANONYMOUS) {
				return Status.AuthenticationRequired;
			} else {
				return Status.Error_Unauthorized;
			}
		}

		//Object Logic
		if (!isValidOid(oid)) {
			return Status.Error_Invalid_Oid;
		}

		if (!fileCache.containsKey(oid)) {
			return Status.Unavailable;
		}

		FilestoreModel item = fileCache.get(oid);

		if (item.getStatus() == Status.Available) {
			return Status.Available;
		}

		return Status.Unavailable;
	}

	@Override
	public FilestoreModel getObject(String oid, UserModel user, RepositoryModel repo) {

		if (canGetObject(oid, user, repo) == Status.Available) {
			return fileCache.get(oid);
		}

		return null;
	}

	@Override
	public FilestoreModel.Status downloadBlob(String oid, UserModel user, RepositoryModel repo, OutputStream streamOut) {

		//Access control and object logic
		Status status = canGetObject(oid, user, repo);

		if (status != Status.Available) {
			return status;
		}

		FilestoreModel item = fileCache.get(oid);

		if (streamOut != null) {
			try (FileInputStream streamIn = new FileInputStream(getStoragePath(oid))) {

				IOUtils.copyLarge(streamIn, streamOut);

				streamOut.flush();
				streamIn.close();
			} catch (EOFException e) {
				logger.error(MessageFormat.format("Client aborted connection for {0}", oid), e);
				return Status.Error_Unexpected_Stream_End;
			} catch (Exception e) {
				logger.error(MessageFormat.format("Failed to download blob {0}", oid), e);
				return Status.Error_Unknown;
			}
		}

		return item.getStatus();
	}

	@Override
	public List<FilestoreModel> getAllObjects(UserModel user) {
		
		final List<RepositoryModel> viewableRepositories = repositoryManager.getRepositoryModels(user);
		List<String> viewableRepositoryNames = new ArrayList<String>(viewableRepositories.size());
		
		for (RepositoryModel repository : viewableRepositories) {
			viewableRepositoryNames.add(repository.name);
		}
		
		if (viewableRepositoryNames.size() == 0) {
			return null;
		}
		
		final Collection<FilestoreModel> allFiles = fileCache.values();
		List<FilestoreModel> userViewableFiles = new ArrayList<FilestoreModel>(allFiles.size());
		
		for (FilestoreModel file : allFiles) {
			if (file.isInRepositoryList(viewableRepositoryNames)) {
				userViewableFiles.add(file);
			}
		}
		
		return userViewableFiles;				
	}

	@Override
	public File getStorageFolder() {
		return runtimeManager.getFileOrFolder(Keys.filestore.storageFolder, "${baseFolder}/lfs");
	}

	@Override
	public File getStoragePath(String oid) {
		 return new File(getStorageFolder(), oid.substring(0, 2).concat("/").concat(oid.substring(2)));
	}

	@Override
	public long getMaxUploadSize() {
		return settings.getLong(Keys.filestore.maxUploadSize, -1);
	}

	@Override
	public long getFilestoreUsedByteCount() {
		Iterator<FilestoreModel> iterator = fileCache.values().iterator();
		long total = 0;

		while (iterator.hasNext()) {

			FilestoreModel item = iterator.next();
			if (item.getStatus() == Status.Available) {
				total += item.getSize();
			}
		}

		return total;
	}

	@Override
	public long getFilestoreAvailableByteCount() {

		try {
			return Files.getFileStore(getStorageFolder().toPath()).getUsableSpace();
		} catch (IOException e) {
			logger.error(MessageFormat.format("Failed to retrive available space in Filestore {0}", e));
		}

		return UNDEFINED_SIZE;
	};

	private synchronized void saveFilestoreModel(FilestoreModel model) {

		File metaFile = new File(getStorageFolder(), METAFILE);
		File metaFileTmp = new File(getStorageFolder(), METAFILE_TMP);
		boolean isNewFile = false;

		try {
			if (!metaFile.exists()) {
				metaFile.getParentFile().mkdirs();
				metaFile.createNewFile();
				isNewFile = true;
			}
			FileUtils.copyFile(metaFile, metaFileTmp);

		} catch (IOException e) {
			logger.error("Writing filestore model to file {0}, {1}", METAFILE, e);
		}

		try (RandomAccessFile fs = new RandomAccessFile(metaFileTmp, "rw")) {

			if (isNewFile) {
				fs.writeBytes("[");
			} else {
				fs.seek(fs.length() - 1);
				fs.writeBytes(",");
			}

			fs.writeBytes(gson().toJson(model));
			fs.writeBytes("]");

			fs.close();

		} catch (IOException e) {
			logger.error("Writing filestore model to file {0}, {1}", METAFILE_TMP, e);
		}

		try {
			if (metaFileTmp.exists()) {
				FileUtils.copyFile(metaFileTmp, metaFile);

				metaFileTmp.delete();
			} else {
				logger.error("Writing filestore model to file {0}", METAFILE);
			}
		}
		catch (IOException e) {
			logger.error("Writing filestore model to file {0}, {1}", METAFILE, e);
		}
	}

	/*
	 * Intended for testing purposes only
	 */
	@Override
	public void clearFilestoreCache() {
		fileCache.clear();
	}

	private static Gson gson(ExclusionStrategy... strategies) {
		GsonBuilder builder = new GsonBuilder();
		builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter());
		if (!ArrayUtils.isEmpty(strategies)) {
			builder.setExclusionStrategies(strategies);
		}
		return builder.create();
	}

}
