package cz.muni.ics.oidc.server.claims.sources;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import cz.muni.ics.oidc.exceptions.ConfigurationException;
import cz.muni.ics.oidc.server.claims.ClaimSource;
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Claim source for generating affiliation values based on VO membership(s).
 *
 * Configuration (replace [claimName] with the name of the claim):
 * <ul>
 *     <li>
 *         <b>custom.claim.[claimName].source.valueMap</b> - Mapping of voIds to affiliation values. Has to be specified
 *         in a format 'voId:aff,aff|voId:aff,aff', where 'voId' is an ID of the VO and 'aff' is the value of an
 *         affiliation to be added to the output if the user is a valid member of the respective VO with the specified
 *         identifier.
 *     </li>
 * </ul>
 *
 * @author Dominik Frantisek Bucik <bucik@ics.muni.cz>
 */
@Slf4j
public class VoBasedEdupersonScopedAffiliationsClaimSource extends ClaimSource {

	private final Pattern epsaPattern = Pattern.compile(
			"(member|student|faculty|staff|alum|affiliate|unknown|library-walk-in)@.+"
	);

	private static final String KEY_VALUE_MAP = "valueMap";

	private final Map<Long, Set<String>> voIdValuesMap = new HashMap<>();

	public VoBasedEdupersonScopedAffiliationsClaimSource(ClaimSourceInitContext ctx) {
		super(ctx);
		String valueMapProp = ctx.getProperty(KEY_VALUE_MAP, null);
		if (!StringUtils.hasText(valueMapProp)) {
			throw new ConfigurationException("Invalid configuration for claim " + getClaimName() + ": valueMap must be provided");
		}
		voIdValuesMap.putAll(parseValueMap(valueMapProp));
		log.debug("{} - voIdAffiliationsMap: '{}'", getClaimName(), voIdValuesMap);
	}

	@Override
	public Set<String> getAttrIdentifiers() {
		return Collections.emptySet();
	}

	@Override
	public JsonNode produceValue(ClaimSourceProduceContext pctx) {
		Long userId = pctx.getPerunUserId();
		Set<String> userAffiliations = new HashSet<>();
		Set<Long> userVoIds = pctx.getPerunAdapter().getUserVoIds(userId);
		for (Long userVoId: userVoIds) {
			Set<String> affiliationsToBeAdded = voIdValuesMap.getOrDefault(userVoId, new HashSet<>());
			if (!affiliationsToBeAdded.isEmpty()) {
				log.trace("{} - added affiliations '{}' due to membership in vo '{}'",
						getClaimName(), affiliationsToBeAdded, userVoId);
				userAffiliations.addAll(affiliationsToBeAdded);
			}
		}
		ArrayNode result = JsonNodeFactory.instance.arrayNode();
		for (String affiliation : userAffiliations) {
			result.add(affiliation);
		}

		log.debug("{} - produced value for user({}): '{}'", getClaimName(), userId, result);
		return result;
	}

	private Map<Long, Set<String>> parseValueMap(String valueMapProp) {
		String[] valueMapParts = valueMapProp.split("\\|");
		if (valueMapParts.length == 0) {
			throw getConfigurationException(
					"Could not parse valueMap property. Needs to be in format voId1:aff1,aff2|voId2:aff3"
			);
		}
		for (String idValue: valueMapParts) {
			if (!StringUtils.hasText(idValue)) {
				throw getConfigurationException(
						"Could not parse id and affiliations mapping, empty String encountered"
				);
			}
			String[] idValueParts = idValue.split(":");
			if (idValueParts.length != 2) {
				throw getConfigurationException(
						"Could not parse id and affiliations mapping. Needs to be in format voId:aff1,aff2"
				);
			}
			long voId;
			try {
				voId = Long.parseLong(idValueParts[0]);
			} catch (NumberFormatException ex) {
				throw getConfigurationException("Could not parse VO id out of subcomponent "  + idValue, ex);
			}
			Set<String> voAffiliations = parseAffiliations(idValueParts[1]);
			if (voAffiliations.isEmpty()) {
				throw getConfigurationException("No affiliation values found for voId " + voId);
			}
			voIdValuesMap.put(voId, voAffiliations);
		}
		return voIdValuesMap;
	}

	private Set<String> parseAffiliations(String idValuePart) {
		String[] affiliations = idValuePart.split(",");
		Set<String> resolvedAffiliations = new HashSet<>();
		for (String affiliation : affiliations) {
			if (!epsaPattern.matcher(affiliation).matches()) {
				throw getConfigurationException(
						"Value '" + affiliation + "' is not a valid eduPersonScopedAffiliation value"
				);
			}
			resolvedAffiliations.add(affiliation);
		}
		return resolvedAffiliations;
	}

	private ConfigurationException getConfigurationException(String message) {
		return getConfigurationException(message, null);
	}

	private ConfigurationException getConfigurationException(String message, Throwable cause) {
		StringBuilder fullMessage = new StringBuilder();
		fullMessage.append("Invalid configuration for claim ").append(getClaimName());
		if (StringUtils.hasText(message)) {
			fullMessage.append(": ").append(message);
		}

		if (cause != null) {
			throw new ConfigurationException(fullMessage.toString(), cause);
		} else {
			throw new ConfigurationException(fullMessage.toString());
		}
	}

}
