/*******************************************************************************
 * Copyright 2Fi Business Solutions Ltd.
 *
 * This code is copyrighted. Under no circumstances should any party, people,
 * or organization should redistribute any portions of this code in any form,
 * either verbatim or through electronic media, to any third parties, unless
 * under explicit written permission by 2Fi Business Solutions Ltd.
 ******************************************************************************/
package com.ffii.core.security.userdetails;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import com.ffii.core.User;

import org.springframework.context.ApplicationContextException;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.support.JdbcDaoSupport;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.util.Assert;


public class UserDetailsServiceImpl extends JdbcDaoSupport implements UserDetailsService {

	// ~ Static fields/initializers =====================================================================================

	public static final String DEF_USERS_BY_USERNAME_QUERY = "SELECT *"
			+ " FROM `users` WHERE deleted = 0 AND username = ?";

	public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = "SELECT u.username, ua.authority"
			+ " FROM `users_authorities` ua"
			+ " LEFT JOIN `users` u ON ua.userId = u.id"
			+ " WHERE u.deleted = 0 AND u.username = ?";

	public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY = "SELECT g.id, g.name, ga.authority"
			+ " FROM `groups_authorities` ga"
			+ " LEFT JOIN `groups` g ON ga.groupId = g.id AND g.deleted = 0"
			+ " LEFT JOIN `groups_users` gu ON ga.groupId = gu.groupId"
			+ " LEFT JOIN `users` u ON gu.userId = u.id"
			+ " WHERE u.deleted = 0 AND u.username = ?";

	// ~ Instance fields ================================================================================================

	protected final MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

	private String usersByUsernameQuery;
	private String authoritiesByUsernameQuery;
	private String groupAuthoritiesByUsernameQuery;

	private boolean enableAuthorities = true;
	private boolean enableGroups;

	// ~ Constructors ===================================================================================================

	public UserDetailsServiceImpl() {
		usersByUsernameQuery = DEF_USERS_BY_USERNAME_QUERY;
		authoritiesByUsernameQuery = DEF_AUTHORITIES_BY_USERNAME_QUERY;
		groupAuthoritiesByUsernameQuery = DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY;
	}

	// ~ Methods ========================================================================================================

	/**
	 * Allows subclasses to add their own granted authorities to the list to be returned in the <tt>UserDetails</tt>.
	 *
	 * @param username
	 *            the username, for use by finder methods
	 * @param authorities
	 *            the current granted authorities, as populated from the <code>authoritiesByUsername</code> mapping
	 */
	protected void addCustomAuthorities(String username, List<GrantedAuthority> authorities) {
		// add ROLE_USER for basic access
		authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
	}

	public String getUsersByUsernameQuery() {
		return usersByUsernameQuery;
	}

	@Override
	protected void initDao() throws ApplicationContextException {
		Assert.isTrue(enableAuthorities || enableGroups, "Use of either authorities or groups must be enabled");
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

		List<User> users = loadUsersByUsername(username);

		if (users.size() == 0) {
			logger.debug("Query returned no results for user '" + username + "'");

			throw new UsernameNotFoundException(
					messages.getMessage("JdbcDaoImpl.notFound", new Object[] { username }, "Username {0} not found"));
		}

		User user = users.get(0); // contains no GrantedAuthority[]

		Set<GrantedAuthority> dbAuthsSet = new HashSet<GrantedAuthority>();

		if (enableAuthorities) {
			dbAuthsSet.addAll(loadUserAuthorities(user.getUsername()));
		}

		if (enableGroups) {
			dbAuthsSet.addAll(loadGroupAuthorities(user.getUsername()));
		}

		List<GrantedAuthority> dbAuths = new ArrayList<GrantedAuthority>(dbAuthsSet);

		addCustomAuthorities(user.getUsername(), dbAuths);

		if (dbAuths.size() == 0) {
			logger.debug("User '" + username + "' has no authorities and will be treated as 'not found'");

			throw new UsernameNotFoundException(
					messages.getMessage("USER.noAuthority", new Object[] { username }, "User {0} has no GrantedAuthority"));
		}

		user.setAuthorities(dbAuths);
		return user;
	}

	/**
	 * Executes the SQL <tt>usersByUsernameQuery</tt> and returns a list of UserDetails objects. There should normally only be one matching user.
	 */
	protected List<User> loadUsersByUsername(String username) {
		return getJdbcTemplate().query(usersByUsernameQuery, new String[] { username }, BeanPropertyRowMapper.newInstance(User.class));
	}

	/**
	 * Loads authorities by executing the SQL from <tt>authoritiesByUsernameQuery</tt>.
	 *
	 * @return a list of GrantedAuthority objects for the user
	 */
	protected List<GrantedAuthority> loadUserAuthorities(String username) {
		return getJdbcTemplate().query(authoritiesByUsernameQuery, new String[] { username }, new RowMapper<GrantedAuthority>() {
			@Override
			public GrantedAuthority mapRow(ResultSet rs, int rowNum) throws SQLException {
				return new SimpleGrantedAuthority(rs.getString("authority"));
			}
		});
	}

	/**
	 * Loads authorities by executing the SQL from <tt>groupAuthoritiesByUsernameQuery</tt>.
	 *
	 * @return a list of GrantedAuthority objects for the user
	 */
	protected List<GrantedAuthority> loadGroupAuthorities(String username) {
		return getJdbcTemplate().query(groupAuthoritiesByUsernameQuery, new String[] { username }, new RowMapper<GrantedAuthority>() {
			@Override
			public GrantedAuthority mapRow(ResultSet rs, int rowNum) throws SQLException {
				return new SimpleGrantedAuthority(rs.getString("authority"));
			}
		});
	}

	/**
	 * Allows the default query string used to retrieve authorities based on username to be overridden, if default table or column names need to be changed. The
	 * default query is {@link #DEF_AUTHORITIES_BY_USERNAME_QUERY}; when modifying this query, ensure that all returned columns are mapped back to the same
	 * column names as in the default query.
	 *
	 * @param queryString
	 *            The SQL query string to set
	 */
	public void setAuthoritiesByUsernameQuery(String queryString) {
		authoritiesByUsernameQuery = queryString;
	}

	protected String getAuthoritiesByUsernameQuery() {
		return authoritiesByUsernameQuery;
	}

	/**
	 * Allows the default query string used to retrieve group authorities based on username to be overridden, if default table or column names need to be
	 * changed. The default query is {@link #DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY}; when modifying this query, ensure that all returned columns are mapped
	 * back to the same column names as in the default query.
	 *
	 * @param queryString
	 *            The SQL query string to set
	 */
	public void setGroupAuthoritiesByUsernameQuery(String queryString) {
		groupAuthoritiesByUsernameQuery = queryString;
	}

	/**
	 * Allows the default query string used to retrieve users based on username to be overridden, if default table or column names need to be changed. The
	 * default query is {@link #DEF_USERS_BY_USERNAME_QUERY}; when modifying this query, ensure that all returned columns are mapped back to the same column
	 * names as in the default query. If the 'enabled' column does not exist in the source database, a permanent true value for this column may be returned by
	 * using a query similar to
	 *
	 * <pre>
	 * &quot;select username,password,'true' as enabled from users where username = ?&quot;
	 * </pre>
	 *
	 * @param usersByUsernameQueryString
	 *            The query string to set
	 */
	public void setUsersByUsernameQuery(String usersByUsernameQueryString) {
		this.usersByUsernameQuery = usersByUsernameQueryString;
	}

	protected boolean getEnableAuthorities() {
		return enableAuthorities;
	}

	/**
	 * Enables loading of authorities from the authorities table. Defaults to true
	 */
	public void setEnableAuthorities(boolean enableAuthorities) {
		this.enableAuthorities = enableAuthorities;
	}

	protected boolean getEnableGroups() {
		return enableGroups;
	}

	/**
	 * Enables support for group authorities. Defaults to false
	 *
	 * @param enableGroups
	 */
	public void setEnableGroups(boolean enableGroups) {
		this.enableGroups = enableGroups;
	}

}
