package yushijinhun.authlibagent.web.manager;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Conjunction;
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.Disjunction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import yushijinhun.authlibagent.dao.TokenRepository;
import yushijinhun.authlibagent.model.Account;
import yushijinhun.authlibagent.model.Token;
import yushijinhun.authlibagent.service.PasswordAlgorithm;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.NotFoundException;
import static java.util.stream.Collectors.toSet;
import static org.hibernate.criterion.Restrictions.conjunction;
import static org.hibernate.criterion.Restrictions.disjunction;
import static org.hibernate.criterion.Restrictions.eq;
import static org.hibernate.criterion.Restrictions.eqOrIsNull;
import static org.hibernate.criterion.Projections.property;
import static com.google.common.base.Strings.emptyToNull;
import static yushijinhun.authlibagent.util.ResourceUtils.requireNonNullBody;
import static yushijinhun.authlibagent.web.manager.AccountInfo.getAccountProfiles;

@Component("accountResource")
public class AccountResourceImpl implements AccountResource {

	@Autowired
	private TokenRepository tokenRepo;

	@Autowired
	protected SessionFactory sessionFactory;

	@Autowired
	private PasswordAlgorithm passwordAlgorithm;

	@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
	@Override
	public Collection<String> getAccounts(String accessToken, String clientToken, Boolean banned, String twitchToken) {
		if (accessToken == null && clientToken == null) {
			// no need for query redis
			return queryAccountsByProperties(banned, twitchToken);
		} else {
			// need to query redis
			Set<String> redisResult = queryAccountsByToken(accessToken, clientToken);
			if (banned == null && twitchToken == null) {
				// no need to query database
				return redisResult;
			} else {
				if (redisResult.isEmpty()) {
					// no result, no need to query database
					return redisResult;
				} else {
					// redis query result UNION database query result
					return queryAccountsByPropertiesInRange(banned, twitchToken, redisResult);
				}
			}
		}
	}

	/**
	 * Queries accounts by the properties of themselves.
	 * 
	 * @param banned null for not query
	 * @param twitchToken null for not query, empty for no token
	 * @return a set of id
	 */
	private Collection<String> queryAccountsByProperties(Boolean banned, String twitchToken) {
		return queryAccountsByCriterion(buildAccountsPropertiesConjunction(banned, twitchToken));
	}

	/**
	 * Queries accounts by tokens.
	 * 
	 * @param accessToken null for not query, cannot be empty
	 * @param clientToken null for not query, cannot be empty
	 * @return a set of id
	 */
	private Set<String> queryAccountsByToken(String accessToken, String clientToken) {
		if (accessToken != null) {
			Token result = tokenRepo.get(accessToken);
			if (result != null && clientToken != null && !clientToken.equals(result.getClientToken())) {
				return Collections.emptySet();
			} else {
				return result == null ? Collections.emptySet() : Collections.singleton(result.getOwner());
			}
		} else {
			return tokenRepo.getByClientToken(clientToken).stream().map(Token::getOwner).collect(toSet());
		}
	}

	/**
	 * Queries accounts by the properties of themselves in the given range.
	 * 
	 * @param banned null for not query
	 * @param twitchToken null for not query, empty for no token
	 * @param range the account range
	 * @return a set of id
	 */
	private Collection<String> queryAccountsByPropertiesInRange(Boolean banned, String twitchToken, Set<String> range) {
		Conjunction propertiesConjunction = buildAccountsPropertiesConjunction(banned, twitchToken);
		Disjunction accountsDisjunction = disjunction();
		range.forEach(id -> accountsDisjunction.add(eq("id", id)));
		return queryAccountsByCriterion(conjunction(propertiesConjunction, accountsDisjunction));
	}

	/**
	 * Queries accounts by criterion.
	 * 
	 * @param criterion criterion
	 * @return a set of id
	 */
	private Collection<String> queryAccountsByCriterion(Criterion criterion) {
		@SuppressWarnings("unchecked")
		List<String> ids = sessionFactory.getCurrentSession().createCriteria(Account.class).add(criterion).setProjection(property("id")).list();
		return ids;
	}

	/**
	 * Builds a conjunction by the properties of accounts.
	 * 
	 * @param banned null for not query
	 * @param twitchToken null for not query, empty for no token
	 * @return conjunction
	 */
	private Conjunction buildAccountsPropertiesConjunction(Boolean banned, String twitchToken) {
		Conjunction conjunction = conjunction();
		if (banned != null) {
			conjunction.add(eq("banned", banned));
		}
		if (twitchToken != null) {
			conjunction.add(eqOrIsNull("twitchToken", emptyToNull(twitchToken)));
		}
		return conjunction;
	}

	@Transactional
	@Override
	public AccountInfo createAccount(AccountInfo info) {
		requireNonNullBody(info);
		if (info.getId() == null) {
			throw new BadRequestException("id cannot be null");
		}

		Session session = sessionFactory.getCurrentSession();
		if (session.get(Account.class, info.getId()) != null) {
			throw new ConflictException("account already exists");
		}

		Account account = new Account();
		fillAccountInfo(account, info);
		session.save(account);
		return new AccountInfo(account);
	}

	@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
	@Override
	public AccountInfo getAccountInfo(String id) {
		return new AccountInfo(lookupAccount(id));
	}

	@Transactional
	@Override
	public void deleteAccount(String id) {
		sessionFactory.getCurrentSession().delete(lookupAccount(id));
		tokenRepo.deleteByAccount(id);
	}

	@Transactional
	@Override
	public AccountInfo updateOrCreateAccount(String id, AccountInfo info) {
		requireNonNullBody(info);

		Session session = sessionFactory.getCurrentSession();
		Account account = session.get(Account.class, id);
		if (account == null) {
			account = new Account();
			account.setId(id);
		}
		fillAccountInfo(account, info);
		session.saveOrUpdate(account);

		return new AccountInfo(account);
	}

	@Transactional
	@Override
	public AccountInfo updateAccount(String id, AccountInfo info) {
		requireNonNullBody(info);

		Account account = lookupAccount(id);
		fillAccountInfo(account, info);
		sessionFactory.getCurrentSession().update(account);

		return new AccountInfo(account);
	}

	private void fillAccountInfo(Account account, AccountInfo info) {
		if (info.getId() != null) {
			if (account.getId() == null) {
				account.setId(info.getId());
			} else if (!account.getId().equals(info.getId())) {
				// changing the id is not allowed
				throw new ConflictException("id conflict");
			}
		}

		if (info.getBanned() != null) {
			account.setBanned(info.getBanned());
		}

		account.setTwitchToken(emptyToNull(info.getTwitchToken()));

		String password = info.getPassword();
		if (password != null) {
			if (password.isEmpty()) {
				account.setPassword(null);
			} else {
				account.setPassword(passwordAlgorithm.hash(password));
			}
		}

		if (info.getProfiles() != null && !info.getProfiles().equals(getAccountProfiles(account))) {
			// changing the profiles is not allowed
			throw new ConflictException("profiles conflict");
		}
	}

	private Account lookupAccount(String id) {
		Session session = sessionFactory.getCurrentSession();
		Account account = session.get(Account.class, id);
		if (account == null) {
			throw new NotFoundException();
		}
		return account;
	}

}