package io.onedev.server.buildspec.job.paramsupply;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Nullable;
import javax.validation.Valid;
import javax.validation.ValidationException;
import javax.validation.constraints.NotNull;

import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang.SerializationUtils;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.hibernate.validator.constraints.NotEmpty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Preconditions;

import io.onedev.server.buildspec.job.Job;
import io.onedev.server.buildspec.job.paramspec.ParamSpec;
import io.onedev.server.buildspec.job.paramspec.SecretParam;
import io.onedev.server.model.Build;
import io.onedev.server.model.support.inputspec.SecretInput;
import io.onedev.server.web.editable.BeanDescriptor;
import io.onedev.server.web.editable.PropertyDescriptor;
import io.onedev.server.web.editable.annotation.Editable;

@Editable
public class ParamSupply implements Serializable {

	private static final long serialVersionUID = 1L;
	
	private static final Logger logger = LoggerFactory.getLogger(ParamSupply.class);
	
	private static final String PARAM_BEAN_PREFIX = "ParamSupplyBean";
	
	private String name;
	
	private boolean secret;
	
	private ValuesProvider valuesProvider = new SpecifiedValues();

	@Editable
	@NotEmpty
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	@Editable
	@NotNull
	@Valid
	public ValuesProvider getValuesProvider() {
		return valuesProvider;
	}

	public void setValuesProvider(ValuesProvider valuesProvider) {
		this.valuesProvider = valuesProvider;
	}

	@Editable
	public boolean isSecret() {
		return secret;
	}

	public void setSecret(boolean secret) {
		this.secret = secret;
	}

	@Override
	public boolean equals(Object other) {
		if (!(other instanceof ParamSupply)) 
			return false;
		if (this == other)
			return true;
		ParamSupply otherParamValue = (ParamSupply) other;
		return new EqualsBuilder()
			.append(name, otherParamValue.name)
			.append(valuesProvider, otherParamValue.valuesProvider)
			.isEquals();
	}

	@Override
	public int hashCode() {
		return new HashCodeBuilder(17, 37)
			.append(name)
			.append(valuesProvider)
			.toHashCode();
	}		
	
	public static void validateParamValues(List<List<String>> values) {
		if (values.isEmpty())
			throw new ValidationException("At least one value needs to be specified");
		Set<List<String>> encountered = new HashSet<>();
		for (List<String> value: values) {
			if (encountered.contains(value)) 
				throw new ValidationException("Duplicate values not allowed");
			else 
				encountered.add(value);
		}
	}
	
	public static void validateParamMatrix(Map<String, ParamSpec> paramSpecMap, Map<String, List<List<String>>> paramMatrix) {
		validateParamNames(paramSpecMap.keySet(), paramMatrix.keySet());
		for (Map.Entry<String, List<List<String>>> entry: paramMatrix.entrySet()) {
			if (entry.getValue() != null) {
				try {
					validateParamValues(entry.getValue());
				} catch (ValidationException e) {
					throw new ValidationException("Error validating values of parameter '" 
							+ entry.getKey() + "': " + e.getMessage());
				}
				
				ParamSpec paramSpec = Preconditions.checkNotNull(paramSpecMap.get(entry.getKey()));
				for (List<String> value: entry.getValue()) 
					validateParamValue(paramSpec, entry.getKey(), value);
			}
		}
	}
	
	private static void validateParamValue(ParamSpec paramSpec, String paramName, List<String> paramValue) {
		try {
			paramSpec.convertToObject(paramValue);
		} catch (Exception e) {
			String displayValue;
			if (paramSpec instanceof SecretParam)
				displayValue = SecretInput.MASK;
			else
				displayValue = paramValue.toString();
			if (e.getMessage() == null)
				logger.error("Error validating field value", e);
			throw new ValidationException("Error validating value '" + displayValue + "' of parameter '" 
					+ paramName + "': " + e.getMessage());
		}
	}

	private static void validateParamNames(Collection<String> paramSpecNames, Collection<String> paramNames) {
		for (String paramSpecName: paramSpecNames) {
			if (!paramNames.contains(paramSpecName))
				throw new ValidationException("Missing job parameter: " + paramSpecName);
		}
		for (String paramName: paramNames) {
			if (!paramSpecNames.contains(paramName))
				throw new ValidationException("Unknown job parameter: " + paramName);
		}
	}
	
	public static void validateParamMap(Map<String, ParamSpec> paramSpecMap, Map<String, List<String>> paramMap) {
		validateParamNames(paramSpecMap.keySet(), paramMap.keySet());
		for (Map.Entry<String, List<String>> entry: paramMap.entrySet()) {
			ParamSpec paramSpec = Preconditions.checkNotNull(paramSpecMap.get(entry.getKey()));
			validateParamValue(paramSpec, entry.getKey(), entry.getValue());
		}
	}
	
	public static Map<String, ParamSpec> getParamSpecMap(List<ParamSpec> paramSpecs) {
		Map<String, ParamSpec> paramSpecMap = new LinkedHashMap<>();
		for (ParamSpec paramSpec: paramSpecs)
			paramSpecMap.put(paramSpec.getName(), paramSpec);
		return paramSpecMap;
	}
	
	public static void validateParams(List<ParamSpec> paramSpecs, List<ParamSupply> params) {
		Map<String, List<List<String>>> paramMap = new HashMap<>();
		for (ParamSupply param: params) {
			List<List<String>> values;
			if (param.getValuesProvider() instanceof SpecifiedValues)
				values = param.getValuesProvider().getValues();
			else
				values = null;
			if (paramMap.put(param.getName(), values) != null)
				throw new ValidationException("Duplicate param: " + param.getName());
		}
		validateParamMatrix(getParamSpecMap(paramSpecs), paramMap);
	}
	
	@SuppressWarnings("unchecked")
	public static Class<? extends Serializable> defineBeanClass(Collection<ParamSpec> paramSpecs) {
		byte[] bytes = SerializationUtils.serialize((Serializable) paramSpecs);
		String className = PARAM_BEAN_PREFIX + "_" + Hex.encodeHexString(bytes);
		
		List<ParamSpec> paramSpecsCopy = new ArrayList<>(paramSpecs);
		for (int i=0; i<paramSpecsCopy.size(); i++) {
			ParamSpec paramSpec = paramSpecsCopy.get(i);
			if (paramSpec instanceof SecretParam) {
				ParamSpec paramSpecClone = (ParamSpec) SerializationUtils.clone(paramSpec);
				String description = paramSpecClone.getDescription();
				if (description == null)
					description = "";
				description += String.format("<div style='margin-top: 12px;'><b>Note:</b> Secret less than %d characters "
						+ "will not be masked in build log</div>", SecretInput.MASK.length());
				paramSpecClone.setDescription(description);
				paramSpecsCopy.set(i, paramSpecClone);
			}
		}
		return (Class<? extends Serializable>) ParamSpec.defineClass(className, "Build Parameters", paramSpecsCopy);
	}
	
	@SuppressWarnings("unchecked")
	@Nullable
	public static Class<? extends Serializable> loadBeanClass(String className) {
		if (className.startsWith(PARAM_BEAN_PREFIX)) {
			byte[] bytes;
			try {
				bytes = Hex.decodeHex(className.substring(PARAM_BEAN_PREFIX.length()+1).toCharArray());
			} catch (DecoderException e) {
				throw new RuntimeException(e);
			}
			List<ParamSpec> paramSpecs = (List<ParamSpec>) SerializationUtils.deserialize(bytes);
			return defineBeanClass(paramSpecs);
		} else {
			return null;
		}
	}

	public static Map<String, List<String>> getParamMap(Job job, Object paramBean, Collection<String> paramNames) {
		Map<String, List<String>> paramMap = new HashMap<>();
		BeanDescriptor descriptor = new BeanDescriptor(paramBean.getClass());
		for (List<PropertyDescriptor> groupProperties: descriptor.getProperties().values()) {
			for (PropertyDescriptor property: groupProperties) {
				if (paramNames.contains(property.getDisplayName()))	{
					Object typedValue = property.getPropertyValue(paramBean);
					ParamSpec paramSpec = Preconditions.checkNotNull(job.getParamSpecMap().get(property.getDisplayName()));
					List<String> values = new ArrayList<>();
					for (String value: paramSpec.convertToStrings(typedValue)) {
						if (paramSpec instanceof SecretParam)
							value = SecretInput.LITERAL_VALUE_PREFIX + value;
						values.add(value);
					}
					paramMap.put(paramSpec.getName(), values);
				}
			}
		}
		return paramMap;
	}

	public static Map<String, List<List<String>>> getParamMatrix(List<ParamSupply> params, @Nullable Build build) {
		Map<String, List<List<String>>> paramMatrix = new LinkedHashMap<>();
		for (ParamSupply param: params) {
			/*
			 * Resolve secret value with current build context as otherwise we may not be authorized to 
			 * access the secret value. This is possible for instance if a pull request triggers a job, 
			 * and then post-action of the job triggers another job with a parameter taking value of 
			 * a secret accessible by the pull request   
			 */
			if (param.isSecret() && build != null) { 
				List<List<String>> resolvedValues = new ArrayList<>();
				for (List<String> value: param.getValuesProvider().getValues()) {
					List<String> resolvedValue = new ArrayList<>();
					for (String each: value) 
						resolvedValue.add(SecretInput.LITERAL_VALUE_PREFIX + build.getSecretValue(each));
					resolvedValues.add(resolvedValue);
				}
				paramMatrix.put(param.getName(), resolvedValues);
			} else {
				paramMatrix.put(param.getName(), param.getValuesProvider().getValues());
			}
		}
		return paramMatrix;
	}

}