package com.ffii.core.user.service;

import java.io.UnsupportedEncodingException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.mail.internet.InternetAddress;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ffii.core.common.ErrorCodes;
import com.ffii.core.common.PasswordRule;
import com.ffii.core.common.mail.pojo.MailRequest;
import com.ffii.core.common.mail.service.MailService;
import com.ffii.core.department.service.DepartmentService;
import com.ffii.core.exception.NotFoundException;
import com.ffii.core.exception.UnprocessableEntityException;
import com.ffii.core.settings.service.SettingsService;
import com.ffii.core.support.AbstractBaseEntityService;
import com.ffii.core.support.JdbcDao;
import com.ffii.core.user.dao.UserDao;
import com.ffii.core.user.entity.User;
import com.ffii.core.user.req.NewUserReq;
import com.ffii.core.user.req.SearchUserReq;
import com.ffii.core.user.req.UpdateUserReq;
import com.ffii.core.user.service.pojo.UserRecord;
import com.ffii.core.utils.LocaleUtils;
import com.ffii.core.utils.Params;
import com.ffii.core.utils.PasswordUtils;

/** @author Fung */
@Service
public class UserService extends AbstractBaseEntityService<User, Integer, UserDao> implements UserDetailsService {
  private static final String USER_AUTH_SQL = "SELECT a.authority FROM user_authority ua"
      + " INNER JOIN authority a ON a.id = ua.authId WHERE ua.userId = :userId";
  private static final String UNION_SQL = " UNION ";
  private static final String GROUP_AUTH_SQL = "SELECT a.authority FROM user_group ug"
      + " INNER JOIN group_authority ga ON ga.groupId = ug.groupId"
      + " INNER JOIN authority a ON a.id = ga.authId WHERE ug.userId = :userId";

  private final String LOAD_AUTH_SQL;

  private PasswordEncoder passwordEncoder;
  private MailService mailService;
  private SettingsService settingsService;
  private UserAttemptService userAttemptService;
  private MessageSource messageSource;
  private DepartmentService departmentService;

  public UserService(
      JdbcDao jdbcDao,
      UserDao dao,
      @Value("${app.auth.user:true}") boolean userAuth,
      @Value("${app.auth.group:false}") boolean groupAuth,
      PasswordEncoder passwordEncoder,
      MailService mailService,
      SettingsService settingsService,
      UserAttemptService userAttemptService,
      MessageSource messageSource,
      DepartmentService departmentService) {
    super(jdbcDao, dao);
    if (userAuth && groupAuth) {
      LOAD_AUTH_SQL = USER_AUTH_SQL + UNION_SQL + GROUP_AUTH_SQL;
    } else if (userAuth) {
      LOAD_AUTH_SQL = USER_AUTH_SQL;
    } else if (groupAuth) {
      LOAD_AUTH_SQL = GROUP_AUTH_SQL;
    } else {
      LOAD_AUTH_SQL = null;
    }

    this.passwordEncoder = passwordEncoder;
    this.mailService = mailService;
    this.settingsService = settingsService;
    this.userAttemptService = userAttemptService;
    this.messageSource = messageSource;
    this.departmentService = departmentService;
  }

  @Override
  public User loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(username));
    Set<GrantedAuthority> auths = new LinkedHashSet<GrantedAuthority>();
    auths.add(new SimpleGrantedAuthority("ROLE_USER"));
    if (LOAD_AUTH_SQL != null) {
      jdbcDao.queryForList(LOAD_AUTH_SQL, Map.of("userId", user.getId()))
          .forEach(item -> auths.add(new SimpleGrantedAuthority((String) item.get("authority"))));
    }
    user.setAuthorities(auths);
    return user;
  }

  public Optional<User> findByUsername(String username) {
    return dao.findByUsernameAndDeletedFalse(username);
  }

  @Transactional(rollbackFor = Exception.class)
  public boolean lock(int id, boolean locked) {
    Optional<User> opt = find(id);
    if (!opt.isPresent()) {
      return false;
    }

    User user = opt.get();
    user.setLocked(locked);
    save(user);
    if (!locked) {
      userAttemptService.reset(user.getUsername());
    }
    return true;
  }

  @Transactional(rollbackFor = Exception.class)
  public boolean setUserLocale(int id, Locale locale) {
    return jdbcDao.executeUpdate(
        "UPDATE user SET locale = :locale WHERE id = :id",
        Map.of(Params.ID, id, "locale", LocaleUtils.toLocaleStr(locale))) > 0;
  }

  public Stream<Integer> findUserIdByAuth(String auth) {
    return jdbcDao.queryForList(
        "SELECT ua.userId"
            + " FROM user_authority ua"
            + " INNER JOIN authority a ON a.id = ua.authId"
            + " WHERE a.authority = :auth",
        Map.of("auth", auth))
        .stream()
        .map(r -> (Integer) r.get("userId"));
  }

  @Transactional(rollbackFor = Exception.class)
  public User newRecord(NewUserReq req) throws UnsupportedEncodingException {
    if (findByUsername(req.getUsername()).isPresent()) {
      throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE);
    }

    String dept = departmentService.getDeptNameById(req.getDeptId());
    String randomPassword = PasswordUtils.genPwd(new PasswordRule(settingsService));
    String pwdHash = passwordEncoder.encode(randomPassword);

    User instance = new User();
    instance.setPassword(pwdHash);
    instance = saveOrUpdate(instance, req);

    Locale locale = instance.getLocale() != null ? LocaleUtils.from(instance.getLocale()) : Locale.ENGLISH;
    mailService.send(
        MailRequest.builder()
            .subject("[MSMS] " + messageSource.getMessage("USER.newAc.subject", null, locale) + " - " + dept)
            .template("mail/newUser")
            .args(Map.of("department", dept, "username", instance.getUsername(), "password",
                StringEscapeUtils.escapeHtml4(randomPassword)))
            .addTo(new InternetAddress(instance.getEmail(), instance.getName()))
            .build(),
        locale);
    return instance;
  }

  @Transactional(rollbackFor = Exception.class)
  public void updateRecord(int id, UpdateUserReq req) {
    saveOrUpdate(
        find(id).orElseThrow(NotFoundException::new),
        req);
  }

  private User saveOrUpdate(User instance, UpdateUserReq req) {
    BeanUtils.copyProperties(req, instance);
    instance = save(instance);
    int id = instance.getId();

    List<Map<String, Integer>> groupBatchInsertValues = req.getAddGroupIds().stream()
        .map(groupId -> Map.of("userId", id, "groupId", groupId))
        .collect(Collectors.toList());
    List<Map<String, Integer>> groupBatchDeleteValues = req.getRemoveGroupIds().stream()
        .map(groupId -> Map.of("userId", id, "groupId", groupId))
        .collect(Collectors.toList());

    if (!groupBatchInsertValues.isEmpty()) {
      jdbcDao.batchUpdate(
          "INSERT IGNORE INTO user_group (groupId,userId)"
              + " VALUES (:groupId, :userId)",
          groupBatchInsertValues);
    }
    if (!groupBatchDeleteValues.isEmpty()) {
      jdbcDao.batchUpdate(
          "DELETE FROM user_group"
              + " WHERE groupId = :groupId AND userId = :userId",
          groupBatchDeleteValues);
    }

    List<Map<String, Integer>> authBatchInsertValues = req.getAddAuthIds().stream()
        .map(authId -> Map.of("userId", id, "authId", authId))
        .collect(Collectors.toList());
    List<Map<String, Integer>> authBatchDeleteValues = req.getRemoveAuthIds().stream()
        .map(authId -> Map.of("userId", id, "authId", authId))
        .collect(Collectors.toList());
    if (!authBatchInsertValues.isEmpty()) {
      jdbcDao.batchUpdate(
          "INSERT IGNORE INTO user_authority (userId, authId)"
              + " VALUES (:userId, :authId)",
          authBatchInsertValues);
    }

    if (!authBatchDeleteValues.isEmpty()) {
      jdbcDao.batchUpdate(
          "DELETE FROM user_authority"
              + " WHERE userId = :userId AND authId = :authId",
          authBatchDeleteValues);
    }

    return instance;
  }

  public Optional<User> findById(int id) {
    return dao.findByIdAndDeletedFalse(id);
  }

  public List<Map<String, Object>> searchForCombo(Map<String, Object> args) {
    StringBuilder sql = new StringBuilder("SELECT"
        + " u.id,"
        + " u.companyId,"
        + " u.department,"
        + " u.username,"
        + " u.name"
        + " FROM user u"
        + " WHERE u.deleted = FALSE");

    if (args != null) {
      if (args.containsKey(Params.QUERY))
        sql.append(" AND (u.username LIKE :query OR u.name LIKE :query)");
      if (args.containsKey(Params.ID))
        sql.append(" AND u.id = :id");
      if (args.containsKey("companyIds"))
        sql.append(" AND u.companyId IN (:companyIds)");

      if (args.containsKey("authority"))
        sql.append(
            " AND (EXISTS(SELECT 1 FROM users_authorities WHERE userId = id AND authority = :authority) OR EXISTS(SELECT 1 FROM user_group gu INNER JOIN groups_authorities ga ON ga.groupId = gu.groupId AND ga.authority = :authority WHERE gu.userId = id))");
    }
    sql.append(" ORDER BY u.name");

    return jdbcDao.queryForList(sql.toString(), args);
  }

  public List<UserRecord> search(SearchUserReq req) {
    StringBuilder sql = new StringBuilder("SELECT"
        + " u.id,"
        + " u.created,"
        + " u.createdBy,"
        + " u.version,"
        + " u.modified,"
        + " u.modifiedBy,"
        + " u.username,"
        + " u.locked,"
        + " u.name,"
        + " u.companyId,"
        + " u.customerId,"
        + " u.locale,"
        + " u.firstname,"
        + " u.lastname,"
        + " u.title,"
        // + " u.department,"
        + "d.name as department,"
        + " u.deptId,"
        + " u.email,"
        + " u.phone1,"
        + " u.phone2,"
        + " u.remarks "
        + " FROM `user` u");

    if (req != null) {
      if (req.getGroupId() != null)
        sql.append(" left join user_group ug on u.id = ug.userId");

      sql.append(" left join department d on u.deptId = d.id");
    }

    sql.append(" where u.deleted = false");

    if (req != null) {
      if (req.getId() != null)
        sql.append(" AND u.id = :id");

      if (req.getGroupId() != null)
        sql.append(" AND ug.groupId = :groupId");
      if (StringUtils.isNotBlank(req.getUsername())) {
        req.setUsername("%" + req.getUsername() + "%");
        sql.append(" AND u.username LIKE :username");
      }
      if (StringUtils.isNotBlank(req.getName())) {
        req.setName("%" + req.getName() + "%");
        sql.append(" AND u.name LIKE :name");
      }
      if (req.getLocked() != null) {
        sql.append(" AND u.locked = :locked");
      }
    }
    sql.append(" ORDER BY u.name");

    if (req != null) {
      if (req.getStart() != null && req.getLimit() != null)
        sql.append(" LIMIT :start, :limit");
    }

    return jdbcDao.queryForList(sql.toString(), req, UserRecord.class);
  }

  public int searchTotalCount(SearchUserReq req) {
    StringBuilder sql = new StringBuilder("SELECT"
        + " COUNT(*) AS count"
        + " FROM user u");

    if (req != null) {
      if (req.getGroupId() != null)
        sql.append(" LEFT JOIN user_group gu on u.id = gu.userId");
    }

    sql.append(" WHERE u.deleted = 0");

    if (req != null) {
      if (req.getId() != null)
        sql.append(" AND u.id = :id");
      if (req.getGroupId() != null)
        sql.append(" AND gu.groupId = :groupId");
      if (StringUtils.isNotBlank(req.getUsername()))
        sql.append(" AND u.username LIKE :username");
      if (StringUtils.isNotBlank(req.getName()))
        sql.append(" AND u.name LIKE :name");
      if (req.getLocked() != null)
        sql.append(" AND u.locked = :locked");
    }

    return jdbcDao.queryForInt(sql.toString(), req);
  }

  public List<Integer> listUserAuthId(int id) {
    return jdbcDao.queryForInts(
        "SELECT"
            + " ua.authId"
            + " FROM user_authority ua"
            + " WHERE ua.userId = :id",
        Map.of(Params.ID, id));
  }

  public List<Integer> listUserGroupId(int id) {
    return jdbcDao.queryForInts(
        "SELECT"
            + " gu.groupId"
            + " FROM user_group gu"
            + " INNER JOIN `group` g ON g.deleted = FALSE AND g.id = gu.groupId"
            + " WHERE gu.userId = :id",
        Map.of(Params.ID, id));
  }

  @Transactional(rollbackFor = Exception.class)
  public void resetPassword(int id) throws UnsupportedEncodingException {
    User instance = find(id).orElseThrow(NotFoundException::new);
    String randomPassword = PasswordUtils.genPwd(new PasswordRule(settingsService));

    String dept = departmentService.getDeptNameById(instance.getDeptId());
    instance.setPassword(passwordEncoder.encode(randomPassword));
    instance = save(instance);

    Locale locale = instance.getLocale() != null ? LocaleUtils.from(instance.getLocale()) : Locale.ENGLISH;
    mailService.send(
        MailRequest.builder()
            .subject("[MSMS] " + messageSource.getMessage("USER.resetPwd.subject", null, locale) + " - " + dept)
            .template("mail/resetPwd")
            .args(Map.of("department", dept, "username", instance.getUsername(), "password",
                StringEscapeUtils.escapeHtml4(randomPassword)))
            .addTo(new InternetAddress(instance.getEmail(), instance.getName()))
            .build(),
        locale);
  }
}
