/*******************************************************************************
 * Copyright 2018 The MIT Internet Trust Consortium
 *
 * Portions copyright 2011-2013 The MITRE Corporation
 *
 * 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 cz.muni.ics.oauth2.web.endpoint;

import cz.muni.ics.oauth2.exception.ProxiedIntrospectionRequestException;
import cz.muni.ics.oauth2.model.ClientDetailsEntity;
import cz.muni.ics.oauth2.model.OAuth2AccessTokenEntity;
import cz.muni.ics.oauth2.model.OAuth2RefreshTokenEntity;
import cz.muni.ics.oauth2.service.ClientDetailsEntityService;
import cz.muni.ics.oauth2.service.IntrospectionResultAssembler;
import cz.muni.ics.oauth2.service.OAuth2TokenEntityService;
import cz.muni.ics.oauth2.service.ProxiedIntrospectionService;
import cz.muni.ics.oauth2.web.AuthenticationUtilities;
import cz.muni.ics.openid.connect.view.HttpCodeView;
import cz.muni.ics.openid.connect.view.JsonEntityView;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import static cz.muni.ics.oauth2.service.IntrospectionResultAssembler.ACTIVE;
import static cz.muni.ics.oauth2.service.IntrospectionResultAssembler.SCOPE;

@Controller
@Slf4j
public class IntrospectionEndpoint {

	public static final String URL = "introspect";

	// === PARAMS === //

	public static final String PARAM_TOKEN = "token";

	public static final String PARAM_TOKEN_TYPE_HINT = "token_type_hint";

	// === HINTS === //

	public static final String HINT_REFRESH_TOKEN = "refresh_token";

	public static final String HINT_ACCESS_TOKEN = "access_token";

	// === FIELDS === //

	private final OAuth2TokenEntityService tokenServices;

	private final ClientDetailsEntityService clientService;

	private final IntrospectionResultAssembler introspectionResultAssembler;

	private final boolean proxiedIntrospectionEnabled;

	private final ProxiedIntrospectionService proxiedIntrospectionService;

	// === CONSTRUCTORS === //

	@Autowired
	public IntrospectionEndpoint(OAuth2TokenEntityService tokenServices,
								 ClientDetailsEntityService clientService,
								 IntrospectionResultAssembler introspectionResultAssembler,
								 @Qualifier("coreProperties") Properties properties) {
		this.tokenServices = tokenServices;
		this.clientService = clientService;
		this.introspectionResultAssembler = introspectionResultAssembler;
		this.proxiedIntrospectionService = ProxiedIntrospectionService.initialize(properties);
		this.proxiedIntrospectionEnabled = proxiedIntrospectionService != null;
	}

	// === PUBLIC METHODS === //

	@RequestMapping("/" + URL)
	public String introspect(@RequestParam(PARAM_TOKEN) String token,
							 @RequestParam(value = PARAM_TOKEN_TYPE_HINT, required = false) String tokenTypeHint,
							 Authentication auth,
							 Model model)
	{
		log.debug("Incoming token introspection request...");
		if (!StringUtils.hasText(token)) {
			log.error("No token provided");
			return codeErrorResponse(model, HttpStatus.BAD_REQUEST);
		}
		if (auth == null) {
			log.error("No authentication object available in the introspection endpoint");
			return codeErrorResponse(model, HttpStatus.UNAUTHORIZED);
		}

		String authClientId = auth.getName();
		if (!StringUtils.hasText(authClientId)) {
			log.error("No client identifier provided when introspecting token");
			return codeErrorResponse(model, HttpStatus.INTERNAL_SERVER_ERROR);
		}

		ClientDetailsEntity authClient = clientService.loadClientByClientId(authClientId);
		if (authClient == null) {
			log.error("No client found for client_id '{}'", authClientId);
			return codeErrorResponse(model, HttpStatus.BAD_REQUEST);
		} else if (!AuthenticationUtilities.hasRole(auth, "ROLE_CLIENT") || !authClient.isAllowIntrospection()) {
			log.error("Client '{}' is not allowed to call introspection endpoint", authClient.getClientId());
			return codeErrorResponse(model, HttpStatus.FORBIDDEN);
		}

		if (tokenTypeHint == null) {
			tokenTypeHint = "";
		}

		log.debug("Incoming valid token introspection request: token - '{}', tokenTypeHint '{}', auth_name: '{}'",
				getTokenForPrint(token), tokenTypeHint, auth.getName());
		return introspectToken(model, token, tokenTypeHint, authClient);
	}

	public static String getTokenForPrint(@NonNull String token) {
		if (token.length() > 3 && token.length() < 20) {
			return token.substring(0, 2) + "..." + token.substring(token.length() - 1);
		}
		return token.substring(0, 10) + "..." + token.substring(token.length() - 10);
	}

	// === PRIVATE METHODS === //

	private String introspectToken(@NonNull Model model,
								   @NonNull String token,
								   @NonNull String tokenTypeHint,
								   @NonNull ClientDetailsEntity authClient) {
		Map<String, Object> entity;
		switch (tokenTypeHint) {
			case HINT_REFRESH_TOKEN: {
				entity = introspectRefreshToken(token, authClient.getScope());
				if (entity == null) {
					entity = introspectAccessToken(token, authClient.getScope());
				}
			} break;
			case HINT_ACCESS_TOKEN:
			default: {
				entity = introspectAccessToken(token, authClient.getScope());
				if (entity == null) {
					entity = introspectRefreshToken(token, authClient.getScope());
				}
			} break;
		}
		if (entity == null) {
			if (proxiedIntrospectionEnabled) {
				entity = proxiedIntrospection(token, tokenTypeHint, authClient);
			} else {
				entity = invalidOrErrorResponse();
			}
		}
		return jsonResponse(model, entity);
	}

	private Map<String, Object> introspectAccessToken(@NonNull String token, @NonNull Set<String> callerScopes) {
		log.debug("Introspecting token as '{}' access token", getTokenForPrint(token));
		OAuth2AccessTokenEntity accessToken;
		try {
			// check access tokens first (includes ID tokens)
			accessToken = tokenServices.readAccessToken(token);
		} catch (InvalidTokenException e) {
			log.debug("Introspecting token as '{}' access token failed - InvalidTokenException", getTokenForPrint(token));
			return null;
		}
		if (accessToken == null) {
			log.debug("Introspecting token as '{}' access token failed - token not found", getTokenForPrint(token));
			return null;
		} else if (accessToken.isExpired()) {
			return invalidOrErrorResponse();
		}
		Map<String, Object> introspectionResult = introspectionResultAssembler.assembleFrom(accessToken, callerScopes);
		if (accessToken.isExpired()) {
			return invalidOrErrorResponse();
		}
		return introspectionResult;
	}

	private Map<String, Object> introspectRefreshToken(@NonNull String token, @NonNull Set<String> callerScopes) {
		log.debug("Introspecting token '{}' as refresh token", getTokenForPrint(token));
		OAuth2RefreshTokenEntity refreshToken;
		try {
			refreshToken = tokenServices.getRefreshToken(token);
		} catch (InvalidTokenException e) {
			log.debug("Introspecting token '{}' as refresh token failed - InvalidTokenException", getTokenForPrint(token));
			return null;
		}

		if (refreshToken == null) {
			log.debug("Introspecting token '{}' as refresh token failed - token not found", getTokenForPrint(token));
			return null;
		} else if (refreshToken.isExpired()) {
			return invalidOrErrorResponse();
		}
		Map<String, Object> introspectionResult = introspectionResultAssembler.assembleFrom(refreshToken, callerScopes);
		if (refreshToken.isExpired()) {
			return invalidOrErrorResponse();
		}
		return introspectionResult;
	}

	private Map<String, Object> proxiedIntrospection(@NonNull String token,
													 @NonNull String tokenTypeHint,
													 @NonNull ClientDetailsEntity authClient)
	{
		log.debug("Trying to introspect token '{}' via proxied introspection", getTokenForPrint(token));
		Map<String, Object> entity;
		try {
			entity = proxiedIntrospectionService.executeIntrospection(token, tokenTypeHint);
		} catch (ProxiedIntrospectionRequestException e) {
			log.debug("Proxied token introspection failed - {}", e.getMessage());
			return invalidOrErrorResponse();
		}
		if (entity == null) {
			return invalidOrErrorResponse();
		} else if (entity.containsKey(SCOPE)) {
			String scope = (String) entity.get(SCOPE);
			if (!StringUtils.hasText(scope)) {
				Set<String> parsedScopes = new HashSet<>(Arrays.asList(scope.split(" ")));
				parsedScopes.retainAll(authClient.getScope());
				String filteredScope = String.join(" ", parsedScopes);
				entity.put(SCOPE, filteredScope);
			}
		}
		return entity;
	}

	public Map<String, Object> invalidOrErrorResponse() {
		return Map.of(ACTIVE, false);
	}

	private String codeErrorResponse(@NonNull Model model, @NonNull HttpStatus code) {
		model.addAttribute(HttpCodeView.CODE, code);
		return HttpCodeView.VIEWNAME;
	}

	private String jsonResponse(@NonNull Model model, @NonNull Map<String, Object> entity) {
		model.addAttribute(JsonEntityView.ENTITY, entity);
		return JsonEntityView.VIEWNAME;
	}

}
