package ru.yandex.qe.dispenser.ws.staff;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import javax.inject.Inject;

import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import ru.yandex.qe.dispenser.api.v1.DiYandexGroupType;
import ru.yandex.qe.dispenser.domain.Person;
import ru.yandex.qe.dispenser.domain.PersonAffiliation;
import ru.yandex.qe.dispenser.domain.PersonGroupMembership;
import ru.yandex.qe.dispenser.domain.PersonGroupMembershipType;
import ru.yandex.qe.dispenser.domain.YaGroup;
import ru.yandex.qe.dispenser.domain.dao.group.GroupDao;
import ru.yandex.qe.dispenser.domain.dao.person.PersonDao;
import ru.yandex.qe.dispenser.domain.dao.person.PersonGroupMembershipDao;
import ru.yandex.qe.dispenser.domain.staff.StaffDepartmentGroup;
import ru.yandex.qe.dispenser.domain.staff.StaffGroup;
import ru.yandex.qe.dispenser.domain.staff.StaffGroupType;
import ru.yandex.qe.dispenser.domain.staff.StaffPage;
import ru.yandex.qe.dispenser.domain.staff.StaffPerson;
import ru.yandex.qe.dispenser.domain.staff.StaffPersonAffiliation;

@Component
public class StaffSyncManager {

    private static final Logger LOG = LoggerFactory.getLogger(StaffSyncManager.class);

    private static final int GROUPS_PAGE_SIZE = 1000;
    private static final int PERSONS_PAGE_SIZE = 100;

    private final StaffApiHelper staffApi;
    private final PersonDao personDao;
    private final GroupDao groupDao;
    private final PersonGroupMembershipDao groupMembershipDao;

    @Inject
    public StaffSyncManager(final StaffApiHelper staffApi, final PersonDao personDao, final GroupDao groupDao,
                            final PersonGroupMembershipDao groupMembershipDao) {
        this.staffApi = staffApi;
        this.personDao = personDao;
        this.groupDao = groupDao;
        this.groupMembershipDao = groupMembershipDao;
    }

    public void syncAll() {
        syncGroups();
        syncPersons();
    }

    private void syncGroups() {
        LOG.info("Syncing staff groups...");
        syncNonDeletedGroups();
        syncDeletedGroups();
        LOG.info("Staff groups synced successfully");
    }

    private void syncPersons() {
        LOG.info("Syncing staff persons...");
        syncNonDeletedPersons();
        syncDeletedPersons();
        LOG.info("Staff persons synced successfully");
    }

    private void syncNonDeletedGroups() {
        final StaffGroupsQuery firstPageQuery = StaffGroupsQuery.builder().build();
        final StaffPage<StaffGroup> firstPage = staffApi.getGroupsOrderedByIdAsc(firstPageQuery, GROUPS_PAGE_SIZE);
        syncGroupsPage(firstPage);
        StaffPage<StaffGroup> lastPage = firstPage;
        Optional<Long> lastId = findLast(lastPage.getResult()).map(StaffGroup::getId);
        while ((lastPage.getTotal() > GROUPS_PAGE_SIZE || lastPage.getResult().size() == GROUPS_PAGE_SIZE) && lastId.isPresent()) {
            final StaffGroupsQuery nextPageQuery = StaffGroupsQuery.builder()
                    .idFilter(StaffGroupsQuery.idGreaterThan(lastId.get())).build();
            lastPage = staffApi.getGroupsOrderedByIdAsc(nextPageQuery, GROUPS_PAGE_SIZE);
            lastId = findLast(lastPage.getResult()).map(StaffGroup::getId);
            syncGroupsPage(lastPage);
        }
    }

    private void syncDeletedGroups() {
        final StaffGroupsQuery firstPageQuery = StaffGroupsQuery.builder().deleted(true).build();
        final StaffPage<StaffGroup> firstPage = staffApi.getGroupsOrderedByIdAsc(firstPageQuery, GROUPS_PAGE_SIZE);
        syncGroupsPage(firstPage);
        long totalPages = firstPage.getPages();
        long currentPage = 1;
        while (currentPage < totalPages) {
            currentPage++;
            final StaffGroupsQuery nextPageQuery = StaffGroupsQuery.builder().deleted(true).build();
            final StaffPage<StaffGroup> nextPage = staffApi.getGroupsOrderedByIdAsc(nextPageQuery, GROUPS_PAGE_SIZE, currentPage);
            totalPages = nextPage.getPages();
            syncGroupsPage(nextPage);
        }
    }

    private void syncNonDeletedPersons() {
        final StaffPersonsQuery firstPageQuery = StaffPersonsQuery.builder().build();
        final StaffPage<StaffPerson> firstPage = staffApi.getPersonsOrderedByIdAsc(firstPageQuery, PERSONS_PAGE_SIZE);
        syncPersonsPage(firstPage);
        StaffPage<StaffPerson> lastPage = firstPage;
        Optional<Long> lastId = findLast(lastPage.getResult()).map(StaffPerson::getId);
        while ((lastPage.getTotal() > PERSONS_PAGE_SIZE || lastPage.getResult().size() == PERSONS_PAGE_SIZE) && lastId.isPresent()) {
            final StaffPersonsQuery nextPageQuery = StaffPersonsQuery.builder()
                    .idFilter(StaffPersonsQuery.idGreaterThan(lastId.get())).build();
            lastPage = staffApi.getPersonsOrderedByIdAsc(nextPageQuery, PERSONS_PAGE_SIZE);
            lastId = findLast(lastPage.getResult()).map(StaffPerson::getId);
            syncPersonsPage(lastPage);
        }
    }

    private void syncDeletedPersons() {
        final StaffPersonsQuery firstPageQuery = StaffPersonsQuery.builder().deleted(true).build();
        final StaffPage<StaffPerson> firstPage = staffApi.getPersonsOrderedByIdAsc(firstPageQuery, PERSONS_PAGE_SIZE);
        syncPersonsPage(firstPage);
        long totalPages = firstPage.getPages();
        long currentPage = 1;
        while (currentPage < totalPages) {
            currentPage++;
            final StaffPersonsQuery nextPageQuery = StaffPersonsQuery.builder().deleted(true).build();
            final StaffPage<StaffPerson> nextPage = staffApi.getPersonsOrderedByIdAsc(nextPageQuery, PERSONS_PAGE_SIZE, currentPage);
            totalPages = nextPage.getPages();
            syncPersonsPage(nextPage);
        }
    }

    private void syncGroupsPage(final StaffPage<StaffGroup> groupsPage) {
        if (groupsPage.getResult().isEmpty()) {
            return;
        }
        final List<StaffGroup> staffGroups = groupsPage.getResult().stream()
                .filter(g -> g.getType() != StaffGroupType.UNKNOWN).collect(Collectors.toList());
        if (staffGroups.isEmpty()) {
            return;
        }
        final Set<Long> receivedStaffIds = staffGroups.stream().map(StaffGroup::getId).collect(Collectors.toSet());
        final Set<YaGroup> knownByStaffIdGroups = groupDao.tryReadYaGroupsByStaffIds(receivedStaffIds);
        final Map<Long, YaGroup> knownGroupsByStaffId = knownByStaffIdGroups.stream().collect(Collectors.toMap(YaGroup::getStaffId, g -> g, (l, r) -> l));
        final Set<YaGroup> groupsToCreate = new HashSet<>();
        final Set<YaGroup> groupsToUpdate = new HashSet<>();
        staffGroups.forEach(staffGroup -> {
            final long staffId = staffGroup.getId();
            final String url = staffGroup.getUrl();
            toType(staffGroup.getType()).ifPresent(actualType -> {
                if (!knownGroupsByStaffId.containsKey(staffId)) {
                    final YaGroup newGroup = new YaGroup(url, actualType, staffId, staffGroup.isDeleted());
                    groupsToCreate.add(newGroup);
                } else if (knownGroupsByStaffId.containsKey(staffId)) {
                    final YaGroup existingGroup = knownGroupsByStaffId.get(staffId);
                    if (!Objects.equals(existingGroup.getUrl(), url) || existingGroup.getType() != actualType
                            || existingGroup.isDeleted() != staffGroup.isDeleted()) {
                        final YaGroup updatedGroup = new YaGroup(url, actualType, staffId, staffGroup.isDeleted());
                        updatedGroup.setId(existingGroup.getId());
                        groupsToUpdate.add(updatedGroup);
                    }
                }
            });
        });
        if (!groupsToCreate.isEmpty()) {
            groupDao.createIfAbsent(groupsToCreate);
        }
        if (!groupsToUpdate.isEmpty()) {
            groupDao.updateAll(groupsToUpdate);
        }
    }

    private void syncPersonsPage(final StaffPage<StaffPerson> personsPage) {
        if (personsPage.getResult().isEmpty()) {
            return;
        }
        final Set<Long> receivedUids = personsPage.getResult().stream().map(p -> Long.valueOf(p.getUid())).collect(Collectors.toSet());
        final Set<Person> knownPersons = personDao.tryReadPersonsByUids(receivedUids);
        final Map<Long, Person> knownPersonsByUids = knownPersons.stream().collect(Collectors.toMap(Person::getUid, p -> p, (l, r) -> l));
        final List<Person> personsToAdd = new ArrayList<>();
        final List<Person> personsToUpdate = new ArrayList<>();
        personsPage.getResult().forEach(staffPerson -> {
            final long uid = Long.parseLong(staffPerson.getUid());
            final PersonAffiliation affiliation = toAffiliation(staffPerson.getOfficial().getAffiliation());
            if (!knownPersonsByUids.containsKey(uid)) {
                final Person newPerson = new Person(staffPerson.getLogin(), uid, staffPerson.getOfficial().isRobot(),
                        staffPerson.getOfficial().isDismissed(), staffPerson.isDeleted(), affiliation);
                personsToAdd.add(newPerson);
            } else {
                final Person existingPerson = knownPersonsByUids.get(uid);
                if (!Objects.equals(existingPerson.getLogin(), staffPerson.getLogin())
                        || existingPerson.isRobot() != staffPerson.getOfficial().isRobot()
                        || existingPerson.isDismissed() != staffPerson.getOfficial().isDismissed()
                        || existingPerson.isDeleted() != staffPerson.isDeleted()
                        || existingPerson.getAffiliation() != affiliation) {
                    final Person updatedPerson = new Person(existingPerson.getId(), staffPerson.getLogin(), uid,
                            staffPerson.getOfficial().isRobot(), staffPerson.getOfficial().isDismissed(), staffPerson.isDeleted(), affiliation);
                    personsToUpdate.add(updatedPerson);
                }
            }
        });
        final Map<Long, Person> addedPersonsByUid;
        if (!personsToAdd.isEmpty()) {
            personDao.createIfAbsent(personsToAdd);
            final Set<Long> addedUids = personsToAdd.stream().map(Person::getUid).collect(Collectors.toSet());
            final Set<Person> addedPersons = personDao.tryReadPersonsByUids(addedUids);
            addedPersonsByUid = addedPersons.stream().collect(Collectors.toMap(Person::getUid, p -> p, (l, r) -> l));
        } else {
            addedPersonsByUid = new HashMap<>();
        }
        if (!personsToUpdate.isEmpty()) {
            personDao.updateAll(personsToUpdate);
        }
        final Map<Long, Person> personsByUid = new HashMap<>();
        personsByUid.putAll(knownPersonsByUids);
        personsByUid.putAll(addedPersonsByUid);
        syncGroupMembership(personsPage.getResult(), personsByUid);
    }

    @SuppressWarnings("ConstantConditions")
    private void syncGroupMembership(final List<StaffPerson> staffPersons, final Map<Long, Person> personsByUid) {
        final List<PersonGroupMembership> memberships = groupMembershipDao.findByPersons(personsByUid.values());
        final Map<Long, YaGroup> groupsByStaffId = prepareGroups(staffPersons, memberships);
        final Map<Long, List<PersonGroupMembership>> membershipsByUid = memberships.stream()
                .collect(Collectors.groupingBy(m -> m.getPerson().getUid()));
        final List<PersonGroupMembership> membershipsToAdd = new ArrayList<>();
        final List<PersonGroupMembership> membershipsToRemove = new ArrayList<>();
        staffPersons.forEach(staffPerson -> {
            final long uid = Long.parseLong(staffPerson.getUid());
            final Person person = personsByUid.get(uid);
            if (person == null) {
                LOG.warn("Person with uid {} and login {} is not found", uid, staffPerson.getLogin());
            }
            final List<PersonGroupMembership> personMemberships = membershipsByUid.getOrDefault(uid, Collections.emptyList());
            final Map<Long, PersonGroupMembership> groupMembershipsByStaffId = new HashMap<>();
            final Map<Long, PersonGroupMembership> departmentMembershipsByStaffId = new HashMap<>();
            final Map<Long, PersonGroupMembership> departmentAncestorsMembershipsByStaffId = new HashMap<>();
            final Set<Long> personGroupsStaffIds = new HashSet<>();
            final Set<Long> personDepartmentAncestorGroupsStaffIds = new HashSet<>();
            splitMemberships(personMemberships, groupMembershipsByStaffId, departmentMembershipsByStaffId, departmentAncestorsMembershipsByStaffId);
            if (staffPerson.getGroups() != null) {
                List<StaffPerson.PersonGroup> personGroups = staffPerson.getGroups();
                personGroups.stream().filter(g -> g.getGroup().getType() != StaffGroupType.UNKNOWN).forEach(group -> {
                    if (!groupMembershipsByStaffId.containsKey(group.getGroup().getId())) {
                        if (checkGroupsExistence(groupsByStaffId, group.getGroup())) {
                            membershipsToAdd.add(new PersonGroupMembership(person, groupsByStaffId.get(group.getGroup().getId()), PersonGroupMembershipType.GROUP));
                        }
                    }
                });
                personGroups.stream().filter(g -> g.getGroup().getType() != StaffGroupType.UNKNOWN)
                        .map(g -> g.getGroup().getId()).forEach(personGroupsStaffIds::add);
            } else {
                LOG.warn("StaffPersons with uid = {} with null groups.", staffPerson.getUid());
            }
            if (staffPerson.getDepartmentGroup() != null) {
                StaffDepartmentGroup staffGroup = staffPerson.getDepartmentGroup();
                if (!departmentMembershipsByStaffId.containsKey(staffGroup.getId())) {
                    if (checkGroupsExistence(groupsByStaffId, staffGroup)) {
                        membershipsToAdd.add(new PersonGroupMembership(person, groupsByStaffId.get(staffGroup.getId()),
                                PersonGroupMembershipType.DEPARTMENT));
                    }
                }

                staffPerson.getDepartmentGroup().getAncestors().forEach(group -> {
                    if (!departmentAncestorsMembershipsByStaffId.containsKey(group.getId())) {
                        if (checkGroupsExistence(groupsByStaffId, group)) {
                            membershipsToAdd.add(new PersonGroupMembership(person, groupsByStaffId.get(group.getId()),
                                    PersonGroupMembershipType.DEPARTMENT_ANCESTORS));
                        }
                    }
                });

                staffGroup.getAncestors().stream()
                        .map(StaffGroup::getId)
                        .forEach(personDepartmentAncestorGroupsStaffIds::add);

                final long personDepartmentGroupStaffId = staffGroup.getId();
                departmentMembershipsByStaffId.forEach((staffId, membership) -> {
                    if (!Objects.equals(staffId, personDepartmentGroupStaffId)) {
                        membershipsToRemove.add(membership);
                    }
                });
            } else {
                departmentMembershipsByStaffId.forEach((staffId, membership) -> membershipsToRemove.add(membership));
                LOG.warn("StaffPersons with uid = {} with null departmentGroup.", staffPerson.getUid());
            }
            groupMembershipsByStaffId.forEach((staffId, membership) -> {
                if (!personGroupsStaffIds.contains(staffId)) {
                    membershipsToRemove.add(membership);
                }
            });
            departmentAncestorsMembershipsByStaffId.forEach((staffId, membership) -> {
                if (!personDepartmentAncestorGroupsStaffIds.contains(staffId)) {
                    membershipsToRemove.add(membership);
                }
            });
        });
        if (!membershipsToAdd.isEmpty()) {
            Lists.partition(membershipsToAdd, 1000).forEach(groupMembershipDao::createIfAbsent);
        }
        if (!membershipsToRemove.isEmpty()) {
            Lists.partition(membershipsToRemove, 1000).forEach(groupMembershipDao::deleteAll);
        }
    }

    private boolean checkGroupsExistence(final Map<Long, YaGroup> groupsByStaffId, final StaffGroup group) {
        if (!groupsByStaffId.containsKey(group.getId())) {
            LOG.warn("Group with staff id {} and url {} is not found", group.getId(), group.getUrl());
            return false;
        }
        return true;
    }

    private Map<Long, YaGroup> prepareGroups(final List<StaffPerson> staffPersons, final List<PersonGroupMembership> memberships) {
        final Map<Long, StaffGroup> groupsByStaffId = new HashMap<>();
        staffPersons.forEach(staffPerson -> {
            if (staffPerson.getGroups() != null) {
                staffPerson.getGroups().stream().filter(g -> g.getGroup().getType() != StaffGroupType.UNKNOWN)
                        .forEach(g -> groupsByStaffId.put(g.getGroup().getId(), g.getGroup()));
            } else {
                LOG.warn("StaffPersons with uid = {} with null groups.", staffPerson.getUid());
            }
            if (staffPerson.getDepartmentGroup() != null) {
                groupsByStaffId.put(staffPerson.getDepartmentGroup().getId(), staffPerson.getDepartmentGroup());
                staffPerson.getDepartmentGroup().getAncestors().forEach(g -> groupsByStaffId.put(g.getId(), g));
            } else {
                LOG.warn("StaffPersons with uid = {} with null departmentGroup.", staffPerson.getUid());
            }
        });
        final Set<Long> groupsStaffIds = groupsByStaffId.keySet();
        final Map<Long, YaGroup> alreadyLoadedGroups = memberships.stream().filter(m -> groupsStaffIds.contains(m.getGroup().getStaffId()))
                .collect(Collectors.toMap(m -> m.getGroup().getStaffId(), PersonGroupMembership::getGroup, (l, r) -> l));
        final Set<Long> groupsToLoadStaffIds = Sets.difference(groupsStaffIds, alreadyLoadedGroups.keySet());
        final Set<YaGroup> loadedByStaffIdGroups = groupsToLoadStaffIds.isEmpty()
                ? Collections.emptySet() : groupDao.tryReadYaGroupsByStaffIds(groupsToLoadStaffIds);
        final Map<Long, YaGroup> loadedGroupsByStaffId = loadedByStaffIdGroups.stream().collect(Collectors.toMap(YaGroup::getStaffId, g -> g, (l, r) -> l));
        final Map<Long, YaGroup> knownGroupsByStaffId = new HashMap<>(alreadyLoadedGroups);
        knownGroupsByStaffId.putAll(loadedGroupsByStaffId);
        final List<YaGroup> groupsToAdd = new ArrayList<>();
        groupsByStaffId.forEach((staffId, staffGroup) -> {
            if (!knownGroupsByStaffId.containsKey(staffId)) {
                Optional<DiYandexGroupType> diYandexGroupType = toType(staffGroup.getType());
                diYandexGroupType.ifPresent(actualType ->
                        groupsToAdd.add(new YaGroup(staffGroup.getUrl(), actualType, staffGroup.getId(), staffGroup.isDeleted())));
            }
        });
        final Map<Long, YaGroup> result = new HashMap<>(knownGroupsByStaffId);
        if (!groupsToAdd.isEmpty()) {
            Lists.partition(groupsToAdd, 1000).forEach(groupDao::createIfAbsent);
            final Set<Long> groupsToAddStaffIds = groupsToAdd.stream().map(YaGroup::getStaffId).collect(Collectors.toSet());
            final Set<YaGroup> addedGroups = groupDao.tryReadYaGroupsByStaffIds(groupsToAddStaffIds);
            addedGroups.forEach(g -> result.put(g.getStaffId(), g));
        }
        return result;
    }

    private void splitMemberships(final List<PersonGroupMembership> personMemberships,
                                  final Map<Long, PersonGroupMembership> groupMembershipsByStaffId,
                                  final Map<Long, PersonGroupMembership> departmentMembershipsByStaffId,
                                  final Map<Long, PersonGroupMembership> departmentAncestorsMembershipsByStaffId) {
        personMemberships.forEach(personMembership -> {
            switch (personMembership.getMembershipType()) {
                case GROUP:
                    groupMembershipsByStaffId.put(personMembership.getGroup().getStaffId(), personMembership);
                    break;
                case DEPARTMENT:
                    departmentMembershipsByStaffId.put(personMembership.getGroup().getStaffId(), personMembership);
                    break;
                case DEPARTMENT_ANCESTORS:
                    departmentAncestorsMembershipsByStaffId.put(personMembership.getGroup().getStaffId(), personMembership);
                    break;
                default:
                    throw new RuntimeException("Unsupported membership type: " + personMembership.getMembershipType());
            }
        });
    }

    private Optional<DiYandexGroupType> toType(final StaffGroupType type) {
        if (type == null) {
            LOG.warn("Group type is null!");
            return Optional.empty();
        }
        switch (type) {
            case DEPARTMENT:
                return Optional.of(DiYandexGroupType.DEPARTMENT);
            case SERVICE_ROLE:
                return Optional.of(DiYandexGroupType.SERVICEROLE);
            case SERVICE:
                return Optional.of(DiYandexGroupType.SERVICE);
            case WIKI:
                return Optional.of(DiYandexGroupType.WIKI);
            default:
                LOG.warn("Unsupported group type: " + type);
                return Optional.empty();
        }
    }

    private PersonAffiliation toAffiliation(final StaffPersonAffiliation affiliation) {
        switch (affiliation) {
            case UNKNOWN:
                return PersonAffiliation.UNKNOWN;
            case YANDEX:
                return PersonAffiliation.YANDEX;
            case YA_MONEY:
                return PersonAffiliation.YA_MONEY;
            case EXTERNAL:
                return PersonAffiliation.EXTERNAL;
            default:
                throw new RuntimeException("Unsupported user affiliation: " + affiliation);
        }
    }

    private <T> Optional<T> findLast(final List<T> list) {
        if (list.isEmpty()) {
            return Optional.empty();
        }
        return Optional.of(list.get(list.size() - 1));
    }

}
