package org.keycloak.protocol.oidc;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;

import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.ClientConnection;
import org.keycloak.OAuth2Constants;
import org.keycloak.constants.ServiceAccountConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.UserSessionProvider;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.Cors;

/**
 * Endpoint for authenticate clients and retrieve service accounts
 *
 * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
 */
public class ServiceAccountManager {

    protected static final Logger logger = Logger.getLogger(ServiceAccountManager.class);

    private TokenManager tokenManager;
    private AuthenticationManager authManager;
    private EventBuilder event;
    private HttpRequest request;
    private MultivaluedMap<String, String> formParams;

    private KeycloakSession session;

    private RealmModel realm;
    private HttpHeaders headers;
    private UriInfo uriInfo;
    private ClientConnection clientConnection;

    private ClientModel client;
    private UserModel clientUser;

    public ServiceAccountManager(TokenManager tokenManager, AuthenticationManager authManager, EventBuilder event, HttpRequest request, MultivaluedMap<String, String> formParams, KeycloakSession session) {
        this.tokenManager = tokenManager;
        this.authManager = authManager;
        this.event = event;
        this.request = request;
        this.formParams = formParams;
        this.session = session;

        this.realm = session.getContext().getRealm();
        this.headers = session.getContext().getRequestHeaders();
        this.uriInfo = session.getContext().getUri();
        this.clientConnection = session.getContext().getConnection();
    }

    public Response buildClientCredentialsGrant() {
        authenticateClient();
        checkClient();
        return finishClientAuthorization();
    }

    protected void authenticateClient() {
        // TODO: This should be externalized into pluggable SPI for client authentication (hopefully Authentication SPI can be reused).
        // Right now, just Client Credentials Grants (as per OAuth2 specs) is supported
        String authorizationHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        client = AuthorizeClientUtil.authorizeClient(authorizationHeader, formParams, event, realm);
        event.detail(Details.CLIENT_AUTH_METHOD, Details.CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS);
    }

    protected void checkClient() {
        if (client.isBearerOnly()) {
            event.error(Errors.INVALID_CLIENT);
            throw new ErrorResponseException("unauthorized_client", "Bearer-only client not allowed to retrieve service account", Response.Status.UNAUTHORIZED);
        }
        if (client.isPublicClient()) {
            event.error(Errors.INVALID_CLIENT);
            throw new ErrorResponseException("unauthorized_client", "Public client not allowed to retrieve service account", Response.Status.UNAUTHORIZED);
        }
        if (!client.isServiceAccountsEnabled()) {
            event.error(Errors.INVALID_CLIENT);
            throw new ErrorResponseException("unauthorized_client", "Client not enabled to retrieve service account", Response.Status.UNAUTHORIZED);
        }
    }

    protected Response finishClientAuthorization() {
        event.detail(Details.RESPONSE_TYPE, ServiceAccountConstants.CLIENT_AUTH);

        Map<String, String> search = new HashMap<>();
        search.put(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId());
        List<UserModel> users = session.users().searchForUserByUserAttributes(search, realm);

        if (users.size() == 0) {
            // May need to handle bootstrap here as well
            logger.warnf("Service account user for client '%s' not found. Creating now", client.getClientId());
            new ClientManager(new RealmManager(session)).enableServiceAccount(client);
            users = session.users().searchForUserByUserAttributes(search, realm);
            clientUser = users.get(0);
        } else if (users.size() == 1) {
            clientUser = users.get(0);
        } else {
            throw new ModelDuplicateException("Multiple service account users found for client '" + client.getClientId() + "' . Check your DB");
        }

        String clientUsername = clientUser.getUsername();
        event.detail(Details.USERNAME, clientUsername);
        event.user(clientUser);

        if (!clientUser.isEnabled()) {
            event.error(Errors.USER_DISABLED);
            throw new ErrorResponseException("invalid_request", "User '" + clientUsername + "' disabled", Response.Status.UNAUTHORIZED);
        }

        String scope = formParams.getFirst(OAuth2Constants.SCOPE);

        UserSessionProvider sessions = session.sessions();

        // TODO: Once more requirements are added, clientSession will be likely created earlier by authentication mechanism
        ClientSessionModel clientSession = sessions.createClientSession(realm, client);
        clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL);
        clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));

        // TODO: Should rather obtain authMethod from client session?
        UserSessionModel userSession = sessions.createUserSession(realm, clientUser, clientUsername, clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null);
        event.session(userSession);

        TokenManager.attachClientSession(userSession, clientSession);

        // Notes about client details
        userSession.setNote(ServiceAccountConstants.CLIENT_ID, client.getClientId());
        userSession.setNote(ServiceAccountConstants.CLIENT_HOST, clientConnection.getRemoteHost());
        userSession.setNote(ServiceAccountConstants.CLIENT_ADDRESS, clientConnection.getRemoteAddr());

        AccessTokenResponse res = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSession)
                .generateAccessToken(session, scope, client, clientUser, userSession, clientSession)
                .generateRefreshToken()
                .generateIDToken()
                .build();

        event.success();

        return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
    }

}
