package ru.yandex.direct.web.entity.useractionlog;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

import javax.annotation.Nullable;
import javax.annotation.ParametersAreNonnullByDefault;

import com.google.common.collect.ImmutableSet;
import io.leangen.graphql.annotations.GraphQLArgument;
import io.leangen.graphql.annotations.GraphQLContext;
import io.leangen.graphql.annotations.GraphQLNonNull;
import io.leangen.graphql.annotations.GraphQLQuery;
import io.leangen.graphql.annotations.GraphQLRootContext;
import one.util.streamex.EntryStream;
import one.util.streamex.StreamEx;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

import ru.yandex.direct.common.db.PpcPropertiesSupport;
import ru.yandex.direct.common.db.PpcProperty;
import ru.yandex.direct.common.db.PpcPropertyNames;
import ru.yandex.direct.core.entity.campaign.model.CampaignTypeKinds;
import ru.yandex.direct.core.entity.campaign.model.CampaignTypeSource;
import ru.yandex.direct.core.entity.client.service.ClientService;
import ru.yandex.direct.core.entity.user.model.User;
import ru.yandex.direct.core.security.DirectAuthentication;
import ru.yandex.direct.core.security.SecurityTranslations;
import ru.yandex.direct.dbutil.sharding.ShardHelper;
import ru.yandex.direct.grid.model.Order;
import ru.yandex.direct.grid.model.campaign.strategy.GdCampaignGoalStrategy;
import ru.yandex.direct.grid.model.entity.campaign.converter.CampaignDataConverter;
import ru.yandex.direct.libs.graphql.GraphqlPublicException;
import ru.yandex.direct.rbac.PpcRbac;
import ru.yandex.direct.rbac.RbacRole;
import ru.yandex.direct.rbac.RbacService;
import ru.yandex.direct.useractionlog.CampaignId;
import ru.yandex.direct.useractionlog.ChangeSource;
import ru.yandex.direct.useractionlog.ClientId;
import ru.yandex.direct.useractionlog.db.ReadActionLogTable;
import ru.yandex.direct.useractionlog.dict.DictDataCategory;
import ru.yandex.direct.useractionlog.dict.DictRequest;
import ru.yandex.direct.useractionlog.reader.FilterLogRecordsByCampaignTypeBuilder;
import ru.yandex.direct.useractionlog.reader.UserActionLogFilter;
import ru.yandex.direct.useractionlog.reader.UserActionLogOffset;
import ru.yandex.direct.useractionlog.reader.UserActionLogReader;
import ru.yandex.direct.useractionlog.reader.model.AdGroupEvent;
import ru.yandex.direct.useractionlog.reader.model.BroadMatchView;
import ru.yandex.direct.useractionlog.reader.model.CampaignEvent;
import ru.yandex.direct.useractionlog.reader.model.InputCategory;
import ru.yandex.direct.useractionlog.reader.model.LogRecord;
import ru.yandex.direct.useractionlog.reader.model.MultiAdEvent;
import ru.yandex.direct.useractionlog.reader.model.MultiAdGroupEvent;
import ru.yandex.direct.useractionlog.reader.model.OutputCategory;
import ru.yandex.direct.useractionlog.reader.model.TimeTargetView;
import ru.yandex.direct.useractionlog.schema.ObjectPath;
import ru.yandex.direct.web.core.security.DirectWebAuthenticationSource;

import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
import static ru.yandex.direct.grid.model.entity.campaign.converter.CampaignDataConverter.toGdCampaignSource;
import static ru.yandex.direct.utils.FunctionalUtils.filterList;
import static ru.yandex.direct.utils.FunctionalUtils.mapList;

@Lazy
@ParametersAreNonnullByDefault
@Service
public class UserActionLogService {
    private static final Logger logger = LoggerFactory.getLogger(UserActionLogService.class);
    private final UserActionLogReader userActionLogReader;
    private final ShardHelper shardHelper;
    private final ObjectPathResolverService objectPathResolverService;
    private final PpcRbac ppcRbac;
    private final RbacService rbacService;
    private final DirectWebAuthenticationSource authenticationSource;
    private final FilterLogRecordsByCampaignTypeBuilder filterLogRecordsByCampaignTypeBuilder;
    private final CampaignTypeSourceCache campaignTypeSourceCache;
    private final ClientService clientService;
    private final PpcProperty<Boolean> showAutoApplyChangesProperty;

    @Autowired
    public UserActionLogService(UserActionLogReader userActionLogReader,
                                ShardHelper shardHelper,
                                ObjectPathResolverService objectPathResolverService,
                                PpcRbac ppcRbac,
                                RbacService rbacService,
                                DirectWebAuthenticationSource authenticationSource,
                                FilterLogRecordsByCampaignTypeBuilder filterLogRecordsByCampaignTypeBuilder,
                                CampaignTypeSourceCache campaignTypeSourceCache,
                                ClientService clientService,
                                PpcPropertiesSupport ppcPropertiesSupport) {
        this.userActionLogReader = userActionLogReader;
        this.shardHelper = shardHelper;
        this.objectPathResolverService = objectPathResolverService;
        this.ppcRbac = ppcRbac;
        this.rbacService = rbacService;
        this.authenticationSource = authenticationSource;
        this.filterLogRecordsByCampaignTypeBuilder = filterLogRecordsByCampaignTypeBuilder;
        this.campaignTypeSourceCache = campaignTypeSourceCache;
        this.clientService = clientService;
        this.showAutoApplyChangesProperty = ppcPropertiesSupport.get(
                PpcPropertyNames.SHOW_AUTO_APPLY_CHANGES_IN_USER_LOGS,
                Duration.ofMinutes(2));
    }

    @GraphQLQuery(name = "goal")
    public CompletableFuture<@GraphQLNonNull GoalView> goalById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext GdCampaignGoalStrategy goalIdGetter) {
        Long goalId = goalIdGetter.getGoalId();
        return getGoalViewInternal(dataLoaderRegistry, goalId);
    }

    @GraphQLQuery(name = "goal")
    public CompletableFuture<@GraphQLNonNull GoalView> goalById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext BroadMatchView broadMatchView) {
        return getGoalViewInternal(dataLoaderRegistry, broadMatchView.getGoalId());
    }

    private CompletableFuture<@GraphQLNonNull GoalView> getGoalViewInternal(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry, @Nullable Long goalId) {
        return GoalView.specialGoalView(goalId)
                .map(CompletableFuture::completedFuture)
                .orElseGet(() -> dataLoaderRegistry.getGoalViewByIdDataLoader()
                        .load(Objects.requireNonNull(goalId)));
    }

    @GraphQLQuery(name = "user")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull UserView> loginByUid(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext LogRecord logRecord) {
        Long uid = logRecord.getUid();
        if (uid == null) {
            return CompletableFuture.completedFuture(UserView.YANDEX_STAFF);
        } else if (uid == UserView.YANDEX_STAFF.getUid()) {
            return CompletableFuture.completedFuture(UserView.YANDEX_STAFF);
        } else if (uid == UserView.AGENCY.getUid()) {
            if (logRecord.getOperatorClientId() == null) {
                logger.warn("operatorClientId == null, but uid == agency."
                        + " That should never be happened. Returned stub login.");
                return CompletableFuture.completedFuture(UserView.AGENCY);
            } else {
                return dataLoaderRegistry.getClientNameByClientIdDataLoader()
                        .load(logRecord.getOperatorClientId())
                        .thenApply(name -> new UserView(UserView.AGENCY.getUid(), name));
            }
        } else {
            return dataLoaderRegistry.getUserViewByUidDataLoader().load(uid);
        }
    }

    @GraphQLQuery(name = "timeZone")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull TimeZoneView> timeZoneById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext TimeTargetView timeTargetView) {
        return dataLoaderRegistry.getTimeZoneViewByIdDataLoader().load(timeTargetView.getTimeZoneId());
    }

    @GraphQLQuery(name = "campaign")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull CampaignView> campaignViewById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext CampaignEvent campaignEvent) {
        return dataLoaderRegistry.getDictRepositoryDataLoader()
                .load(new DictRequest(DictDataCategory.CAMPAIGN_NAME, campaignEvent.getCampaignId()))
                .thenApply(dictResponse -> toCampaignView(campaignEvent, dictResponse))
                .thenApply(campaignView -> {
                    if (StringUtils.isEmpty(campaignView.getName())) {
                        logger.warn("Not found name for campaign {}", campaignView.getId());
                    }
                    return campaignView;
                });
    }

    private CampaignView toCampaignView(CampaignEvent campaignEvent, Optional<Object> dictResponse) {
        long campaignId = campaignEvent.getCampaignId();
        CampaignTypeSource campaignTypeSource = campaignTypeSourceCache.getTypeSourceMap().get(campaignId);
        var optionalType = Optional.ofNullable(campaignTypeSource).map(CampaignTypeSource::getCampaignType);
        var source = Optional.ofNullable(campaignTypeSource)
                .map(typeSource -> toGdCampaignSource(typeSource.getCampaignsSource()))
                .orElse(null);
        return new CampaignView(
                campaignId,
                dictResponse.map(String.class::cast).orElse(""),
                optionalType.map(CampaignDataConverter::toGdCampaignType).orElse(null),
                source,
                CampaignTypeKinds.CPM.contains(optionalType.orElse(null)) ? CalcType.CPM : CalcType.CPC);
    }

    @GraphQLQuery(name = "adGroup")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull AdGroupView> adGroupViewById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext AdGroupEvent adGroupEvent) {
        if (adGroupEvent.getAdGroupId() == null) {
            return CompletableFuture.completedFuture(new AdGroupView(null, ""));
        }
        return dataLoaderRegistry.getDictRepositoryDataLoader()
                .load(new DictRequest(DictDataCategory.ADGROUP_NAME, adGroupEvent.getAdGroupId()))
                .thenApply(dictResponse -> new AdGroupView(
                        adGroupEvent.getAdGroupId(),
                        dictResponse.map(String.class::cast).orElse("")))
                .thenApply(adGroupView -> {
                    if (StringUtils.isEmpty(adGroupView.getName())) {
                        logger.warn("Not found name for adgroup {}", adGroupView.getId());
                    }
                    return adGroupView;
                });
    }

    @GraphQLQuery(name = "adGroups")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull List<AdGroupView>> adGroupsViewById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext MultiAdGroupEvent multiAdGroupEvent) {
        List<DictRequest> adGroupIds = multiAdGroupEvent.getAdGroupIds().stream()
                .map(id -> new DictRequest(DictDataCategory.ADGROUP_NAME, id))
                .collect(Collectors.toList());
        return dataLoaderRegistry.getDictRepositoryDataLoader()
                .loadMany(adGroupIds)
                .thenApply(dictResponses -> StreamEx.of(adGroupIds).zipWith(StreamEx.of(dictResponses))
                        .mapKeys(DictRequest::getId)
                        .mapValues(x -> String.class.cast(x.orElse("")))
                        .mapKeyValue(AdGroupView::new)
                        .peek(adGroupView -> {
                            if (StringUtils.isEmpty(adGroupView.getName())) {
                                logger.warn("Not found name for adgroup {}", adGroupView.getId());
                            }
                        })
                        .collect(Collectors.toList()));
    }

    @GraphQLQuery(name = "ads")
    @SuppressWarnings("unused")
    public CompletableFuture<@GraphQLNonNull List<AdView>> adsViewById(
            @GraphQLRootContext UserActionLogDataLoaderRegistry dataLoaderRegistry,
            @GraphQLContext MultiAdEvent multiAdEvent) {
        List<DictRequest> adIds = multiAdEvent.getAdIds().stream()
                .map(id -> new DictRequest(DictDataCategory.AD_TITLE, id))
                .collect(Collectors.toList());
        return dataLoaderRegistry.getDictRepositoryDataLoader()
                .loadMany(adIds)
                .thenApply(dictResponses -> StreamEx.of(adIds).zipWith(StreamEx.of(dictResponses))
                        .mapKeys(DictRequest::getId)
                        .mapValues(x -> String.class.cast(x.orElse("")))
                        .mapKeyValue(AdView::new)
                        .peek(adView -> {
                            if (StringUtils.isEmpty(adView.getTitle())) {
                                logger.warn("Not found title for ad {}", adView.getId());
                            }
                        })
                        .collect(Collectors.toList()));
    }

    @GraphQLQuery(name = "userActionLog")
    @SuppressWarnings("unused")
    public @GraphQLNonNull
    UserActionLogContext executeUserActionLog(
            @GraphQLArgument(name = "logins") @Nullable List<@GraphQLNonNull String> logins,
            @GraphQLArgument(name = "limit") @GraphQLNonNull int limit,
            @GraphQLArgument(name = "pageToken") @Nullable String pageToken,
            @GraphQLArgument(name = "dateFrom") @Nullable LocalDateTime dateFrom,
            @GraphQLArgument(name = "dateTo") @Nullable LocalDateTime dateTo,
            @GraphQLArgument(name = "clientLogin") @Nullable String clientLogin,
            @Deprecated @GraphQLArgument(name = "clientId") @Nullable Long clientId,
            @GraphQLArgument(name = "campaignIds") @Nullable List<@GraphQLNonNull Long> campaignIds,
            @GraphQLArgument(name = "adGroupIds") @Nullable List<@GraphQLNonNull Long> adGroupIds,
            @GraphQLArgument(name = "adIds") @Nullable List<@GraphQLNonNull Long> adIds,
            @GraphQLArgument(name = "categories") @Nullable List<@GraphQLNonNull InputCategory> categories,
            @GraphQLArgument(name = "order") @Nullable Order order,
            @GraphQLArgument(name = "changeSources") @Nullable List<@GraphQLNonNull ChangeSource> changeSources) {
        DirectAuthentication directAuthentication = authenticationSource.getAuthentication();
        // у логина приоритет, clientId надо оторвать
        if (clientLogin != null) {
            try {
                clientId = shardHelper.getClientIdByLogin(clientLogin);
            } catch (IllegalArgumentException ex) {
                throw new GraphqlPublicException(
                        "Client is not found by login",
                        SecurityTranslations.INSTANCE.accessDenied());
            }
        }
        checkAccess(directAuthentication.getOperator().getUid(), clientId);

        UserActionLogFilter filter = new UserActionLogFilter();
        if (dateFrom != null) {
            filter.withDateFrom(dateFrom);
        }
        if (dateTo != null) {
            filter.withDateTo(dateTo);
        }

        campaignIds = Optional.ofNullable(campaignIds).orElseGet(ArrayList::new);
        adGroupIds = Optional.ofNullable(adGroupIds).orElseGet(ArrayList::new);
        adIds = Optional.ofNullable(adIds).orElseGet(ArrayList::new);

        // у логина приоритет, clientId надо оторвать
        if (clientLogin != null) {
            clientId = shardHelper.getClientIdByLogin(clientLogin);
        }
        if (clientId == null) {
            throw new IllegalArgumentException("clientId or clientLogin should be specified");
        }

        filterIds(clientId,
                ImmutableSet.copyOf(campaignIds),
                ImmutableSet.copyOf(adGroupIds),
                ImmutableSet.copyOf(adIds)
        ).forEach(filter::withPath);

        if (categories != null) {
            filter.withCategories(categories);
        }

        if (!showAutoApplyChangesProperty.getOrDefault(false)) {
            filter.withoutCategories(singletonList(OutputCategory.CAMPAIGN_AUTO_APPLY_PERMISSIONS));
        }

        if (changeSources != null) {
            filter.withChangeSources(changeSources);
        }

        if (logins != null) {
            List<Long> loginUids = filterList(shardHelper.getUidsByLogin(logins), uid -> uid != null && uid != 0);
            User subjectUser = directAuthentication.getSubjectUser();
            if (logins.contains(UserView.YANDEX_STAFF.getLogin()) && !subjectUser.getRole().isInternal()) {
                // Если обычный клиент хочет посмотреть изменения, сделанные фиктивным пользователем "yandex",
                // то это равносильно просмотру изменений внутренними пользователями, поэтому добавляем в фильтр
                // к uid'ам ВСЕХ внутренних пользователей.
                // Иначе логин "yandex" в фильтр не попадет, так как он фиктивный и его нет в базе.
                List<RbacRole> internalRoles = Arrays.stream(RbacRole.values())
                        .filter(RbacRole::isInternal)
                        .collect(Collectors.toList());
                List<Long> internalUserUids = clientService.getClientChiefUidsByRoles(internalRoles);
                loginUids.addAll(internalUserUids);
            }
            filter.withOperatorUids(loginUids);
        }

        UserActionLogOffset requestOffset = null;
        if (pageToken != null) {
            requestOffset = UserActionLogOffset.fromToken(pageToken);
        }
        UserActionLogReader.FilterResult result = userActionLogReader.filterActionLog(
                filter,
                limit,
                requestOffset,
                order == Order.DESC
                        ? ReadActionLogTable.Order.DESC
                        : ReadActionLogTable.Order.ASC,
                filterLogRecordsByCampaignTypeBuilder.builder()
                        .withClientId(clientId)
                        .build());

        campaignTypeSourceCache.initialize(result.getRecords());

        return substituteUidsByPerms(directAuthentication, result.getRecords(), result.getOffset());
    }

    /**
     * Фильтрация в соответствии с DIRECT-78030
     */
    Collection<ObjectPath> filterIds(Long clientId, Set<Long> campaignIds, Set<Long> adGroupIds,
                                     Set<Long> adIds) {
        ClientId clientIdObj = new ClientId(clientId);
        if (!adIds.isEmpty()) {
            return objectPathResolverService.resolve(ImmutableSet.of(), adIds).stream()
                    .map(ObjectPath.AdPath.class::cast)
                    .filter(p -> p.getClientId().equals(clientIdObj))
                    .filter(p -> campaignIds.isEmpty() || campaignIds.contains(p.getCampaignId().toLong()))
                    .filter(p -> adGroupIds.isEmpty() || adGroupIds.contains(p.getAdGroupId().toLong()))
                    .collect(Collectors.toList());
        } else if (!adGroupIds.isEmpty()) {
            return objectPathResolverService.resolve(adGroupIds, ImmutableSet.of()).stream()
                    .map(ObjectPath.AdGroupPath.class::cast)
                    .filter(p -> p.getClientId().equals(clientIdObj))
                    .filter(p -> campaignIds.isEmpty() || campaignIds.contains(p.getCampaignId().toLong()))
                    .collect(Collectors.toList());
        } else if (!campaignIds.isEmpty()) {
            return campaignIds.stream()
                    .map(id -> new ObjectPath.CampaignPath(clientIdObj, new CampaignId(id)))
                    .collect(Collectors.toList());
        } else {
            return singleton(new ObjectPath.ClientPath(clientIdObj));
        }
    }

    private void checkAccess(Long operatorUserId, @Nullable Long clientId) {
        if (clientId == null) {
            throw new GraphqlPublicException(
                    "Client id is not defined",
                    SecurityTranslations.INSTANCE.accessDenied());
        }

        if (accessDenied(operatorUserId, ru.yandex.direct.dbutil.model.ClientId.fromLong(clientId))) {
            throw new GraphqlPublicException(
                    String.format("Operator with uid %s cannot access client with id %s", operatorUserId, clientId),
                    SecurityTranslations.INSTANCE.accessDenied());
        }
    }

    private boolean accessDenied(long operatorUserId, ru.yandex.direct.dbutil.model.ClientId clientId) {
        return !rbacService.isOwner(operatorUserId, rbacService.getChiefByClientId(clientId));
    }

    /**
     * DIRECT-76205
     * <p>
     * Проверяет через rbac, следует ли запрашивающей стороне знать информацию о конкретных операторах, сделавших
     * изменение. Например, обычный клиент будет видеть в событиях только свой логин. Если в кампании сделал изменение
     * Суперпользователь, Начальник отдела, Тимлидер, Менеджер, саппорт, Медиапланер или Вешальщик, то обычный клиент
     * увидит вместо логина информацию о некоем абстрактном сотруднике яндекса. А суперпользователь и менеджер будут
     * видеть все логины.
     * <p>
     * Скрывать следует не только логины, но и uid-ы, по которым можно однозначно определить логин.
     * <p>
     * DIRECT-80105
     * <p>
     * Чтобы не рассказать клиенту, который представился агентством, информацию о контракте агентства с Яндексом,
     * было решено исправить логику:
     * <ul>
     * <li>Сотрудники Яндекса видят все логины.</li>
     * <li>Агентства видят свои и клиентский логины, с которых вносили изменения.</li>
     * <li>Клиенты вместо агентского логина видят название агентства.</li>
     * </ul>
     */
    private UserActionLogContext substituteUidsByPerms(DirectAuthentication directAuthentication,
                                                       List<LogRecord> records,
                                                       @Nullable UserActionLogOffset offset) {
        Map<Long, Long> operatorClientIdByUid = shardHelper.getClientIdsByUids(records.stream()
                .map(LogRecord::getUid)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet()));

        User subjectUser = directAuthentication.getSubjectUser();
        if (!subjectUser.getRole().isInternal()) {
            Long agencyClientId = subjectUser.getAgencyClientId();
            Set<Long> internalClientIds = EntryStream.of(
                    ppcRbac.getClientsRoles(
                            mapList(operatorClientIdByUid.values(), ru.yandex.direct.dbutil.model.ClientId::fromLong)))
                    .filterKeyValue((clientId, rbacRole) -> rbacRole.isInternal())
                    .keys()
                    .map(ru.yandex.direct.dbutil.model.ClientId::asLong)
                    .collect(Collectors.toSet());
            for (LogRecord record : records) {
                Long operatorClientId = operatorClientIdByUid.get(record.getUid());
                record.setOperatorClientId(operatorClientId);
                // Представитель по работе с клиентами должен видеть логины своих коллег
                if (operatorClientId != null && !Objects.equals(
                        subjectUser.getClientId().asLong(),
                        operatorClientId)) {
                    if (Objects.equals(operatorClientId, agencyClientId)) {
                        // Клиент агентства должен видеть название агентства вместо логина пользователя,
                        // совершившего изменение.
                        record.setUid(UserView.AGENCY.getUid());
                        record.setOperatorClientId(agencyClientId);
                    } else if (internalClientIds.contains(operatorClientId)) {
                        // Если изменение сделал пользователь с ролью сотрудника яндекса, то запрашивающая сторона
                        // не должна видеть его логин. Но только не в случае, когда они работают в одном агентстве.
                        record.setUid(UserView.YANDEX_STAFF.getUid());
                        record.setOperatorClientId(null);
                    }
                }
            }
        } else {
            records.forEach(r -> r.setOperatorClientId(operatorClientIdByUid.get(r.getUid())));
        }
        return new UserActionLogContext(records, offset);
    }
}
