package com.atlassian.bitbucket.internal.key.ssh;

import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.bitbucket.AuthorisationException;
import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.event.user.UserCleanupEvent;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.internal.key.ssh.dao.AoSshKey;
import com.atlassian.bitbucket.internal.key.ssh.dao.SshKeyDao;
import com.atlassian.bitbucket.internal.ssh.InternalSshKeyService;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.ssh.DuplicateSshKeyException;
import com.atlassian.bitbucket.ssh.SshKey;
import com.atlassian.bitbucket.ssh.event.SshKeyCreatedEvent;
import com.atlassian.bitbucket.ssh.event.SshKeyDeletedEvent;
import com.atlassian.bitbucket.ssh.util.KeyUtils;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.user.UserService;
import com.atlassian.bitbucket.user.UserType;
import com.atlassian.bitbucket.util.*;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.sal.api.transaction.TransactionCallback;
import com.atlassian.stash.experimental.user.ExperimentalPermissionAdminService;
import com.atlassian.stash.experimental.user.ProjectPermissionSearchRequest;
import com.atlassian.stash.experimental.user.RepositoryPermissionSearchRequest;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.Validator;
import java.security.PublicKey;
import java.security.interfaces.DSAPublicKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;

import static com.atlassian.bitbucket.util.PageUtils.asPageOf;
import static com.google.common.base.Preconditions.checkNotNull;

public class DefaultSshKeyService implements InternalSshKeyService {

    static final int LIMIT = 25;
    static final PageRequest PAGE_REQUEST_OF_1 = PageUtils.newRequest(0, 1);

    private static final Logger log = LoggerFactory.getLogger(DefaultSshKeyService.class);

    private final ActiveObjects ao;
    private final AuthenticationContext authenticationContext;
    private final EventPublisher eventPublisher;
    private final I18nService i18nService;
    private final SshKeyDao keyDao;
    private final ExperimentalPermissionAdminService permissionAdminService;
    private final PermissionService permissionService;
    private final SecurityService securityService;
    private final UserService userService;
    private final Validator validator;

    public DefaultSshKeyService(ActiveObjects ao, AuthenticationContext authContext,
                                EventPublisher eventPublisher, I18nService i18nService,
                                ExperimentalPermissionAdminService permissionAdminService,
                                PermissionService permissionService, SecurityService securityService,
                                SshKeyDao keyDao, UserService userService, Validator validator) {
        this.ao = ao;
        this.authenticationContext = authContext;
        this.eventPublisher = eventPublisher;
        this.i18nService = i18nService;
        this.keyDao = keyDao;
        this.permissionAdminService = permissionAdminService;
        this.permissionService = permissionService;
        this.securityService = securityService;
        this.userService = userService;
        this.validator = validator;
    }

    @Override
    @Nonnull
    public SshKey addForUser(@Nonnull ApplicationUser user, @Nonnull String keyText) {
        return addForUser(user, keyText, null);
    }

    @Override
    @Nonnull
    public SshKey addForUser(@Nonnull ApplicationUser user, @Nonnull String keyText, @Nullable String keyLabel) {
        return internalAddForUser(user, keyText, keyLabel);
    }

    @Override
    public boolean canEditSshKeyForUser(@Nonnull ApplicationUser user) {
        checkNotNull(user, "user");

        ApplicationUser currentUser = authenticationContext.getCurrentUser();
        if (currentUser != null && user.getId() == currentUser.getId()) {
            // user is manipulating keys for himself, which is always allowed
            return true;
        }

        if (permissionService.hasGlobalPermission(user, Permission.SYS_ADMIN)) {
            return permissionService.hasGlobalPermission(Permission.SYS_ADMIN);
        } else if (user.getType() == UserType.NORMAL) {
            return permissionService.hasGlobalPermission(Permission.ADMIN);
        } else {
            // for access keys a user needs to be at least a repo admin of some project or repo
            return permissionService.hasAnyUserPermission(Permission.REPO_ADMIN);
        }
    }

    @Nonnull
    @Override
    public Page<SshKey> findAllForUser(@Nonnull final ApplicationUser user, @Nullable final PageRequest pageRequest) {
        checkNotNull(user.getId(), "user.id");
        checkCanEditSshKeyForUser(user);

        return ao.executeInTransaction(new TransactionCallback<Page<SshKey>>() {

            @Override
            public Page<SshKey> doInTransaction() {
                return initialize(keyDao.findByUser(user.getId(), limit(pageRequest)), user);
            }
        });
    }

    /**
     * Returns the user who owns the given public key.
     * <p>
     * Unsecured as it is required to define the current user for an ssh request
     *
     * @param key the public key whose owner will be returned
     * @return the owner of the key, or null if the public key is not known
     */
    @Nullable
    @Override
    public ApplicationUser findUserByPublicKey(@Nonnull final PublicKey key) {
        checkNotNull(key, "key");

        return isValidKeyType(key) ? ao.executeInTransaction(new TransactionCallback<ApplicationUser>() {

            @Override
            public ApplicationUser doInTransaction() {
                AoSshKey sshKey = initialize(keyDao.getByUserPublicKey(key));

                return sshKey == null ? null : sshKey.getUser();
            }
        }) : null;
    }

    // unsecured as it is used to determine the current user / context for an SSH request
    @Nullable
    public SshKey getByPublicKey(@Nonnull final PublicKey key) {
        checkNotNull(key, "key");

        return isValidKeyType(key) ? ao.executeInTransaction(new TransactionCallback<AoSshKey>() {

            @Override
            public AoSshKey doInTransaction() {
                return initialize(keyDao.getByPublicKey(key));
            }
        }) : null;
    }

    @Override
    @Nullable
    public SshKey getById(final int id) {
        return ao.executeInTransaction(new TransactionCallback<AoSshKey>() {

            @Override
            public AoSshKey doInTransaction() {
                AoSshKey key = initialize(keyDao.getById(id));
                if (key != null) {
                    if (key.getUser() == null) {
                        //An orphaned key
                        keyDao.delete(key);
                        return null;
                    }
                    checkCanEditSshKeyForUser(key.getUser());
                }
                return key;
            }
        });
    }

    @Override
    public boolean hasSshKey(@Nonnull final ApplicationUser user) {
        checkNotNull(user, "user");
        checkNotNull(user.getId(), "user.id");
        checkCanEditSshKeyForUser(user);

        return ao.executeInTransaction(new TransactionCallback<Boolean>() {

            @Override
            public Boolean doInTransaction() {
                return keyDao.existsForUser(user.getId());
            }
        });
    }

    @EventListener
    public void onUserDeleted(UserCleanupEvent event) {
        //this never happens for service users, only normal users
        doRemoveAllForUser(event.getDeletedUser());
    }

    @Override
    public void remove(final int id) {
        ao.executeInTransaction(new TransactionCallback<Void>() {
            @Override
            public Void doInTransaction() {
                AoSshKey sshKey = initialize(keyDao.getById(id));
                if (sshKey != null) {
                    if (sshKey.getUser() != null) {
                        checkCanEditSshKeyForUser(sshKey.getUser());
                    }

                    // at this point the key is either the user's own key or it is
                    // an orphaned key which can always be deleted. No perms required
                    keyDao.delete(sshKey);

                    eventPublisher.publish(new SshKeyDeletedEvent(this, sshKey));
                }
                return null;
            }
        });
    }

    @Override
    public void removeAllForUser(@Nonnull ApplicationUser user) {
        checkCanEditSshKeyForUser(user);
        doRemoveAllForUser(user);
    }

    @Override
    public boolean removeIfOrphaned(@Nonnull final SshKey key, @Nonnull final ApplicationUser serviceUser) {
        return securityService.withPermission(Permission.ADMIN,
                "Checking SSH key to remove orphans")
                .call(new UncheckedOperation<Boolean>() {

                    @Override
                    public Boolean perform() {
                        if (countProjects() == 0 && countRepositories() == 0) {
                            remove(key.getId());

                            return true;
                        }

                        return false;
                    }

                    private int countProjects() {
                        return permissionAdminService.searchProjects(
                                new ProjectPermissionSearchRequest.Builder()
                                        .user(serviceUser)
                                        .build(),
                                PAGE_REQUEST_OF_1).getSize();
                    }

                    private int countRepositories() {
                        return permissionAdminService.searchRepositories(
                                new RepositoryPermissionSearchRequest.Builder()
                                        .user(serviceUser)
                                        .build(),
                                PAGE_REQUEST_OF_1).getSize();
                    }
                });
    }

    protected void doRemoveAllForUser(@Nonnull final ApplicationUser user) {
        checkNotNull(user, "user");
        checkNotNull(user.getId(), "user.id");

        ao.executeInTransaction(new TransactionCallback<Void>() {

            @Override
            public Void doInTransaction() {
                log.debug("\"{}\" removing all ssh key access entries for user \"{}\"", currentUserName(), user.getDisplayName());
                PageRequest request = PageUtils.newRequest(0, LIMIT);
                Page<AoSshKey> page = keyDao.findByUser(user.getId(), request);
                while (page != null && page.getSize() > 0) {
                    for (AoSshKey key : page.getValues()) {
                        // remove them one by one so we can raise the relevant events
                        keyDao.delete(key);
                        eventPublisher.publish(new SshKeyDeletedEvent(this, initialize(key, user)));
                    }
                    page = page.getIsLastPage() ? null : keyDao.findByUser(user.getId(), request);
                }
                log.debug("\"{}\" removed all ssh key access entries for user \"{}\"", currentUserName(), user.getDisplayName());
                return null;
            }
        });
    }

    private void cleanupStaleKeyOrThrow(AoSshKey key) {
        ApplicationUser user = key.getUser();
        if (user != null) {
            log.debug("Duplicate key encountered with MD5 hash: {}, username: {}", key.getMD5(), user.getName());
            if (user.getType() == UserType.NORMAL) {
                throw new DuplicateSshKeyException(i18nService.createKeyedMessage("bitbucket.service.ssh.key.duplicate", user.getName()));
            } else if (!removeIfOrphaned(key, user)) {
                throw new DuplicateSshKeyException(i18nService.createKeyedMessage("bitbucket.service.ssh.key.resource.usage"));
            }
        }

        log.info("Key with id {} is not assigned to a user. Deleting the key", key.getId());

        keyDao.delete(key);
        eventPublisher.publish(new SshKeyDeletedEvent(this, initialize(key)));
    }

    private SshKey internalAddForUser(final ApplicationUser user, final String keyText, final String keyLabel) {
        checkNotNull(user, "user");

        if (!authenticationContext.isAuthenticated()) {
            throw newUnauthorizedException();
        }

        checkCanEditSshKeyForUser(user);

        ValidationUtils.validate(validator, new ValidatingSshKey(keyText));

        return ao.executeInTransaction(new TransactionCallback<SshKey>() {

            @Override
            public SshKey doInTransaction() {
                PublicKey publicKey = KeyUtils.getPublicKey(keyText);
                AoSshKey key = initialize(keyDao.getByPublicKey(publicKey));

                if (key != null) {
                    // key already exists, but it may be garbage that can be recycled.
                    cleanupStaleKeyOrThrow(key);
                }

                String label = StringUtils.isBlank(keyLabel) ? KeyUtils.getKeyComment(keyText) : keyLabel;
                AoSshKey newKey = keyDao.create(user, keyText, label);
                newKey = initialize(newKey, user);

                log.debug("{} added an ssh key for {}", currentUserName(), user.getName());
                eventPublisher.publish(new SshKeyCreatedEvent(this, newKey));
                return newKey;
            }
        });
    }

    @SuppressWarnings("ConstantConditions")
    private String currentUserName() {
        return authenticationContext.isAuthenticated() ? authenticationContext.getCurrentUser().getDisplayName() :
                "Anonymous user";
    }

    private PageRequest limit(PageRequest pageRequest) {
        return pageRequest == null  ? PageUtils.newRequest(0, LIMIT) : pageRequest.buildRestrictedPageRequest(LIMIT);
    }

    private Page<SshKey> initialize(Page<AoSshKey> keys, ApplicationUser knownOwner) {
        for (AoSshKey key : keys.getValues()) {
            initialize(key, knownOwner);
        }
        return asPageOf(SshKey.class, keys);
    }

    private AoSshKey initialize(AoSshKey key) {
        return initialize(key, null);
    }

    private AoSshKey initialize(AoSshKey key, ApplicationUser knownUser) {
        if (key == null) {
            return null;
        }

        if (knownUser == null) {
            // check the key and load the context or user
            key.initialize(userService.getUserById(key.getUserId()));
        } else {
            // the user is known in advance, no need to hit the database
            key.initialize(knownUser);
        }
        return key;
    }

    private void checkCanEditSshKeyForUser(ApplicationUser user) {
        if (!canEditSshKeyForUser(user)) {
            throw newUnauthorizedException();
        }
    }

    private AuthorisationException newUnauthorizedException() {
        throw new AuthorisationException(i18nService.createKeyedMessage("bitbucket.service.ssh.key.edit.permissions.error"));
    }

    private boolean isValidKeyType(PublicKey key) {
        if (key instanceof RSAPublicKey || key instanceof DSAPublicKey || key instanceof ECPublicKey) {
            return true;
        }

        log.warn("Unsupported key type: {}", key.getAlgorithm());
        return false;
    }
}
