/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.nifi.audit;

import org.apache.nifi.action.Action;
import org.apache.nifi.action.Component;
import org.apache.nifi.action.FlowChangeAction;
import org.apache.nifi.action.Operation;
import org.apache.nifi.action.component.details.FlowChangeRemoteProcessGroupDetails;
import org.apache.nifi.action.details.ActionDetails;
import org.apache.nifi.action.details.ConfigureDetails;
import org.apache.nifi.action.details.FlowChangeConfigureDetails;
import org.apache.nifi.groups.RemoteProcessGroup;
import org.apache.nifi.remote.RemoteGroupPort;
import org.apache.nifi.util.StringUtils;
import org.apache.nifi.web.api.dto.RemoteProcessGroupDTO;
import org.apache.nifi.web.api.dto.RemoteProcessGroupPortDTO;
import org.apache.nifi.web.dao.RemoteProcessGroupDAO;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Function;

import static org.apache.nifi.web.api.dto.DtoFactory.SENSITIVE_VALUE_MASK;

/**
 * Audits remote process group creation/removal and configuration changes.
 */
@Service
@Aspect
public class RemoteProcessGroupAuditor extends NiFiAuditor {

    private static final Logger logger = LoggerFactory.getLogger(RemoteProcessGroupAuditor.class);

    // Proxy settings should be able to be null cleared, so the necessity of checking those properties depend on
    // whether transport protocol is specified.
    // See StandardRemoteProcessGroupDAO.updateRemoteProcessGroup for detail.
    private static final Function<RemoteProcessGroupDTO, Boolean> IS_TRANSPORT_PROTOCOL_SET = dto -> dto.getTransportProtocol() != null;

    private static final List<ConfigurationRecorder<RemoteProcessGroup, RemoteProcessGroupDTO>> CONFIG_RECORDERS = Arrays.asList(
            new ConfigurationRecorder<>("Communications Timeout",
                    dto -> dto.getCommunicationsTimeout() != null, RemoteProcessGroup::getCommunicationsTimeout),
            new ConfigurationRecorder<>("Yield Duration",
                    dto -> dto.getYieldDuration() != null, RemoteProcessGroup::getYieldDuration),
            new ConfigurationRecorder<>("Transport Protocol",
                    IS_TRANSPORT_PROTOCOL_SET, rpg -> rpg.getTransportProtocol().name()),
            new ConfigurationRecorder<>("Proxy Host",
                    IS_TRANSPORT_PROTOCOL_SET, RemoteProcessGroup::getProxyHost),
            new ConfigurationRecorder<>("Proxy Port",
                    IS_TRANSPORT_PROTOCOL_SET, RemoteProcessGroup::getProxyPort),
            new ConfigurationRecorder<>("Proxy User",
                    IS_TRANSPORT_PROTOCOL_SET, RemoteProcessGroup::getProxyUser),
            new ConfigurationRecorder<>("Proxy Password",
                    IS_TRANSPORT_PROTOCOL_SET, RemoteProcessGroup::getProxyPassword)
                    .setConvertRawValue(v -> StringUtils.isEmpty(v) ? "" : SENSITIVE_VALUE_MASK)
    );

    private static final BiFunction<RemoteGroupPort, String, String> PORT_NAME_CONVERT = (updated, name) -> updated.getName() + "." + name;
    private static final List<ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>> PORT_CONFIG_RECORDERS = Arrays.asList(
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Transmission",
                    dto -> dto.isTransmitting() != null, RemoteGroupPort::isRunning)
                    .setConvertName(PORT_NAME_CONVERT)
                    .setConvertRawValue(v -> Boolean.valueOf(v) ? "enabled" : "disabled"),
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Concurrent Tasks",
                    dto -> dto.getConcurrentlySchedulableTaskCount() != null, RemoteGroupPort::getMaxConcurrentTasks)
                    .setConvertName(PORT_NAME_CONVERT),
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Compressed",
                    dto -> dto.getUseCompression() != null, RemoteGroupPort::isUseCompression)
                    .setConvertName(PORT_NAME_CONVERT),
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Batch Count",
                    dto -> dto.getBatchSettings() != null && dto.getBatchSettings().getCount() != null, RemoteGroupPort::getBatchCount)
                    .setConvertName(PORT_NAME_CONVERT),
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Batch Size",
                    dto -> dto.getBatchSettings() != null && dto.getBatchSettings().getSize() != null, RemoteGroupPort::getBatchSize)
                    .setConvertName(PORT_NAME_CONVERT),
            new ConfigurationRecorder<RemoteGroupPort, RemoteProcessGroupPortDTO>("Batch Duration",
                    dto -> dto.getBatchSettings() != null && dto.getBatchSettings().getDuration() != null, RemoteGroupPort::getBatchDuration)
                    .setConvertName(PORT_NAME_CONVERT)
    );

    /**
     * Audits the creation of remote process groups via createRemoteProcessGroup().
     *
     * This method only needs to be run 'after returning'. However, in Java 7 the order in which these methods are returned from Class.getDeclaredMethods (even though there is no order guaranteed)
     * seems to differ from Java 6. SpringAOP depends on this ordering to determine advice precedence. By normalizing all advice into Around advice we can alleviate this issue.
     *
     * @param proceedingJoinPoint join point
     * @return group
     * @throws java.lang.Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.RemoteProcessGroupDAO+) && "
            + "execution(org.apache.nifi.groups.RemoteProcessGroup createRemoteProcessGroup(java.lang.String, org.apache.nifi.web.api.dto.RemoteProcessGroupDTO))")
    public RemoteProcessGroup createRemoteProcessGroupAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        // create the remote process group
        RemoteProcessGroup remoteProcessGroup = (RemoteProcessGroup) proceedingJoinPoint.proceed();

        // if no exceptions were thrown, add the remote process group action...
        // get the creation audits
        final Action action = generateAuditRecord(remoteProcessGroup, Operation.Add);

        // save the actions
        if (action != null) {
            saveAction(action, logger);
        }

        return remoteProcessGroup;
    }

    /**
     * Provides a higher order functions to compute Configuration audit events.
     * @param <CMP> Class of a component, such as RemoteProcessGroup or RemoteGroupPort
     * @param <DTO> Class of a DTO, such as RemoteProcessGroupDTO or RemoteProcessGroupPortDTO
     */
    private static class ConfigurationRecorder<CMP, DTO> {
        final String name;
        final Function<DTO, Boolean> hasInput;
        final Function<CMP, Object> getValue;
        private Function<String, String> convertRawValue;
        private BiFunction<CMP, String, String> convertName;

        /**
         * Create a recorder for a configuration property.
         * @param name name of the target property
         * @param hasInput a function that returns whether the property is being updated by a request
         * @param getValue a function that returns value of the property
         */
        private ConfigurationRecorder(String name, Function<DTO, Boolean> hasInput, Function<CMP, Object> getValue) {
            this.name = name;
            this.hasInput = hasInput;
            this.getValue = getValue;
        }

        private String convertRawValue(final String value) {
            return convertRawValue != null ? convertRawValue.apply(value) : value;
        }

        /**
         * If a property value needs to be converted for audit record, e.g. sensitive values,
         * use this method to specify a function to convert raw value.
         * @param convertRawValue a function to convert string representation of a property value
         * @return converted value
         */
        private ConfigurationRecorder<CMP, DTO> setConvertRawValue(final Function<String, String> convertRawValue) {
            this.convertRawValue = convertRawValue;
            return this;
        }

        /**
         * If a property name needs to be decorated depends on other values in a context,
         * use this method to specify a function to convert name.
         * @param convertName a function to convert name of a property
         * @return converted name
         */
        private ConfigurationRecorder<CMP, DTO> setConvertName(final BiFunction<CMP, String, String> convertName) {
            this.convertName = convertName;
            return this;
        }

        private ConfigureDetails checkConfigured(final DTO input,
                                                 final CMP updated,
                                                 final Object previousValue) {

            final Object updatedValue = getValue.apply(updated);
            // Convert null to empty String to avoid NullPointerException.
            final String updatedStr = updatedValue != null ? updatedValue.toString() : "";
            final String previousStr = previousValue != null ? previousValue.toString() : "";
            if (hasInput.apply(input) && !updatedStr.equals(previousStr)) {

                FlowChangeConfigureDetails configDetails = new FlowChangeConfigureDetails();
                configDetails.setName(convertName != null ? convertName.apply(updated, name) : name);
                configDetails.setPreviousValue(convertRawValue(previousStr));
                configDetails.setValue(convertRawValue(updatedStr));
                return configDetails;
            }
            return null;
        }

        /**
         * Capture values before a component to be updated. This method should be called before proceeding a joint point.
         * @param recorders list of ConfigurationRecorder
         * @param component a target component to capture
         * @param <C> Class of the target component
         * @param <D> DTO class of the target component
         * @return captured values keyed with its name
         */
        private static <C, D> Map<String, Object> capturePreviousValues(final List<ConfigurationRecorder<C, D>> recorders, final C component) {
            final Map<String, Object> previousValues = new HashMap<>(recorders.size());
            recorders.forEach(r -> previousValues.put(r.name, r.getValue.apply(component)));
            return previousValues;
        }

        /**
         * Generate ActionDetails for properties those have been updated.
         * This method should be called after proceeding a joint point with an updated component.
         * @param recorders list of ConfigurationRecorder
         * @param dto DTO instance containing requested values
         * @param updatedComponent a component instance that is updated by corresponding DAO
         * @param previousValues previous property values before being updated
         * @param details a Collection to accumulate action details generated
         * @param <C> Class of the target component
         * @param <D> DTO class of the target component
         */
        private static <C, D> void checkConfigured(final List<ConfigurationRecorder<C, D>> recorders, final D dto, final C updatedComponent,
                                                   final Map<String, Object> previousValues, final Collection<ActionDetails> details) {
            recorders.stream()
                    .map(r -> r.checkConfigured(dto, updatedComponent, previousValues.get(r.name)))
                    .filter(Objects::nonNull).forEach(d -> details.add(d));

        }
    }

    /**
     * Audits the update of remote process group configuration.
     *
     * @param proceedingJoinPoint   join point
     * @param remoteProcessGroupDTO dto
     * @param remoteProcessGroupDAO dao
     * @return group
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.RemoteProcessGroupDAO+) && "
            + "execution(org.apache.nifi.groups.RemoteProcessGroup updateRemoteProcessGroup(org.apache.nifi.web.api.dto.RemoteProcessGroupDTO)) && "
            + "args(remoteProcessGroupDTO) && "
            + "target(remoteProcessGroupDAO)")
    public RemoteProcessGroup auditUpdateProcessGroupConfiguration(
            ProceedingJoinPoint proceedingJoinPoint, RemoteProcessGroupDTO remoteProcessGroupDTO, RemoteProcessGroupDAO remoteProcessGroupDAO) throws Throwable {
        final RemoteProcessGroup remoteProcessGroup = remoteProcessGroupDAO.getRemoteProcessGroup(remoteProcessGroupDTO.getId());

        // record the current value of this remoteProcessGroups configuration for comparisons later
        final boolean transmissionState = remoteProcessGroup.isTransmitting();
        final Map<String, Object> previousValues = ConfigurationRecorder.capturePreviousValues(CONFIG_RECORDERS, remoteProcessGroup);

        // perform the underlying operation
        final RemoteProcessGroup updatedRemoteProcessGroup = (RemoteProcessGroup) proceedingJoinPoint.proceed();

        if (isAuditable()) {
            final Collection<ActionDetails> details = new ArrayList<>();

            // see if any property has changed
            ConfigurationRecorder.checkConfigured(CONFIG_RECORDERS, remoteProcessGroupDTO, updatedRemoteProcessGroup, previousValues, details);

            final Date timestamp = new Date();
            final Collection<Action> actions = new ArrayList<>();

            // create the remote process group details
            final FlowChangeRemoteProcessGroupDetails remoteProcessGroupDetails = createFlowChangeDetails(remoteProcessGroup);

            // save the actions if necessary
            if (!details.isEmpty()) {

                // create the actions
                for (ActionDetails detail : details) {
                    // create a configure action for each updated property
                    final FlowChangeAction remoteProcessGroupAction = createFlowChangeAction(timestamp,
                            updatedRemoteProcessGroup, remoteProcessGroupDetails);
                    remoteProcessGroupAction.setOperation(Operation.Configure);
                    remoteProcessGroupAction.setActionDetails(detail);

                    actions.add(remoteProcessGroupAction);
                }
            }

            // determine the new executing state
            // using isConfiguredToTransmit() as opposed to isTransmitting()
            // to capture case where port is still in process of shutting down.
            boolean updatedConfiguredToTransmitState = updatedRemoteProcessGroup.isConfiguredToTransmit();
            // determine if the running state has been set to change
            if (transmissionState != updatedConfiguredToTransmitState) {
                // create a remote process group action
                final FlowChangeAction remoteProcessGroupAction = createFlowChangeAction(timestamp,
                        updatedRemoteProcessGroup, remoteProcessGroupDetails);

                // set the operation accordingly
                if (updatedConfiguredToTransmitState) {
                    remoteProcessGroupAction.setOperation(Operation.Start);
                } else {
                    remoteProcessGroupAction.setOperation(Operation.Stop);
                }
                actions.add(remoteProcessGroupAction);
            }

            // ensure there are actions to record
            if (!actions.isEmpty()) {
                // save the actions
                saveActions(actions, logger);
            }
        }

        return updatedRemoteProcessGroup;
    }

    private FlowChangeAction createFlowChangeAction(final Date timestamp,
                                                    final RemoteProcessGroup remoteProcessGroup,
                                                    final FlowChangeRemoteProcessGroupDetails remoteProcessGroupDetails) {

        final FlowChangeAction remoteProcessGroupAction = createFlowChangeAction();
        remoteProcessGroupAction.setTimestamp(timestamp);
        remoteProcessGroupAction.setSourceId(remoteProcessGroup.getIdentifier());
        remoteProcessGroupAction.setSourceName(remoteProcessGroup.getName());
        remoteProcessGroupAction.setSourceType(Component.RemoteProcessGroup);
        remoteProcessGroupAction.setComponentDetails(remoteProcessGroupDetails);
        return remoteProcessGroupAction;
    }

    /**
     * Audits the removal of a process group via deleteProcessGroup().
     *
     * @param proceedingJoinPoint join point
     * @param remoteProcessGroupId remote group id
     * @param remoteProcessGroupDAO remote group dao
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.RemoteProcessGroupDAO+) && "
            + "execution(void deleteRemoteProcessGroup(java.lang.String)) && "
            + "args(remoteProcessGroupId) && "
            + "target(remoteProcessGroupDAO)")
    public void removeRemoteProcessGroupAdvice(ProceedingJoinPoint proceedingJoinPoint, String remoteProcessGroupId, RemoteProcessGroupDAO remoteProcessGroupDAO) throws Throwable {
        // get the remote process group before removing it
        RemoteProcessGroup remoteProcessGroup = remoteProcessGroupDAO.getRemoteProcessGroup(remoteProcessGroupId);

        // remove the remote process group
        proceedingJoinPoint.proceed();

        // if no exceptions were thrown, add removal actions...
        final Action action = generateAuditRecord(remoteProcessGroup, Operation.Remove);

        // save the actions
        if (action != null) {
            saveAction(action, logger);
        }
    }

    private RemoteGroupPort auditUpdateProcessGroupPortConfiguration(ProceedingJoinPoint proceedingJoinPoint, RemoteProcessGroupPortDTO remoteProcessGroupPortDto,
                                                                     RemoteProcessGroup remoteProcessGroup, RemoteGroupPort remoteProcessGroupPort) throws Throwable {
        final Map<String, Object> previousValues = ConfigurationRecorder.capturePreviousValues(PORT_CONFIG_RECORDERS, remoteProcessGroupPort);

        // perform the underlying operation
        final RemoteGroupPort updatedRemoteProcessGroupPort = (RemoteGroupPort) proceedingJoinPoint.proceed();

        if (isAuditable()) {
            final Collection<ActionDetails> details = new ArrayList<>();

            // see if any property has changed
            ConfigurationRecorder.checkConfigured(PORT_CONFIG_RECORDERS, remoteProcessGroupPortDto, updatedRemoteProcessGroupPort, previousValues, details);

            final Date timestamp = new Date();
            final Collection<Action> actions = new ArrayList<>();

            // create the remote process group details
            final FlowChangeRemoteProcessGroupDetails remoteProcessGroupDetails = createFlowChangeDetails(remoteProcessGroup);

            // save the actions if necessary
            for (ActionDetails detail : details) {
                // create a configure action for each updated property
                FlowChangeAction remoteProcessGroupAction = createFlowChangeAction(timestamp,
                        remoteProcessGroup, remoteProcessGroupDetails);
                remoteProcessGroupAction.setOperation(Operation.Configure);
                remoteProcessGroupAction.setActionDetails(detail);

                actions.add(remoteProcessGroupAction);
            }

            // ensure there are actions to record
            if (!actions.isEmpty()) {
                // save the actions
                saveActions(actions, logger);
            }
        }

        return updatedRemoteProcessGroupPort;
    }

    /**
     * Audits the update of remote process group input port configuration.
     *
     * @param proceedingJoinPoint       join point
     * @param remoteProcessGroupPortDto dto
     * @param remoteProcessGroupDAO     dao
     * @return group
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.RemoteProcessGroupDAO+) && "
            + "execution(org.apache.nifi.remote.RemoteGroupPort updateRemoteProcessGroupInputPort(java.lang.String, org.apache.nifi.web.api.dto.RemoteProcessGroupPortDTO)) && "
            + "args(remoteProcessGroupId, remoteProcessGroupPortDto) && "
            + "target(remoteProcessGroupDAO)")
    public RemoteGroupPort auditUpdateProcessGroupInputPortConfiguration(
            ProceedingJoinPoint proceedingJoinPoint, String remoteProcessGroupId,
            RemoteProcessGroupPortDTO remoteProcessGroupPortDto, RemoteProcessGroupDAO remoteProcessGroupDAO) throws Throwable {

        final RemoteProcessGroup remoteProcessGroup = remoteProcessGroupDAO.getRemoteProcessGroup(remoteProcessGroupId);
        final RemoteGroupPort remoteProcessGroupPort = remoteProcessGroup.getInputPort(remoteProcessGroupPortDto.getId());

        return auditUpdateProcessGroupPortConfiguration(proceedingJoinPoint, remoteProcessGroupPortDto, remoteProcessGroup, remoteProcessGroupPort);
    }

    /**
     * Audits the update of remote process group output port configuration.
     *
     * @param proceedingJoinPoint join point
     * @param remoteProcessGroupPortDto dto
     * @param remoteProcessGroupDAO dao
     * @return group
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.RemoteProcessGroupDAO+) && "
            + "execution(org.apache.nifi.remote.RemoteGroupPort updateRemoteProcessGroupOutputPort(java.lang.String, org.apache.nifi.web.api.dto.RemoteProcessGroupPortDTO)) && "
            + "args(remoteProcessGroupId, remoteProcessGroupPortDto) && "
            + "target(remoteProcessGroupDAO)")
    public RemoteGroupPort auditUpdateProcessGroupOutputPortConfiguration(
            ProceedingJoinPoint proceedingJoinPoint, String remoteProcessGroupId,
            RemoteProcessGroupPortDTO remoteProcessGroupPortDto, RemoteProcessGroupDAO remoteProcessGroupDAO) throws Throwable {

        final RemoteProcessGroup remoteProcessGroup = remoteProcessGroupDAO.getRemoteProcessGroup(remoteProcessGroupId);
        final RemoteGroupPort remoteProcessGroupPort = remoteProcessGroup.getOutputPort(remoteProcessGroupPortDto.getId());

        return auditUpdateProcessGroupPortConfiguration(proceedingJoinPoint, remoteProcessGroupPortDto, remoteProcessGroup, remoteProcessGroupPort);
    }

    private FlowChangeRemoteProcessGroupDetails createFlowChangeDetails(RemoteProcessGroup remoteProcessGroup) {
        FlowChangeRemoteProcessGroupDetails remoteProcessGroupDetails = new FlowChangeRemoteProcessGroupDetails();
        remoteProcessGroupDetails.setUri(remoteProcessGroup.getTargetUri());
        return remoteProcessGroupDetails;
    }

    /**
     * Generates an audit record for the specified remote process group.
     *
     * @param remoteProcessGroup group
     * @param operation operation
     * @return action
     */
    public Action generateAuditRecord(RemoteProcessGroup remoteProcessGroup, Operation operation) {
        return generateAuditRecord(remoteProcessGroup, operation, null);
    }

    /**
     * Generates an audit record for the specified remote process group.
     *
     * @param remoteProcessGroup group
     * @param operation operation
     * @param actionDetails details
     * @return action
     */
    public Action generateAuditRecord(RemoteProcessGroup remoteProcessGroup, Operation operation, ActionDetails actionDetails) {
        FlowChangeAction action = null;

        if (isAuditable()) {
            // create the remote process group details
            FlowChangeRemoteProcessGroupDetails remoteProcessGroupDetails = createFlowChangeDetails(remoteProcessGroup);

            // create the remote process group action
            action = createFlowChangeAction();
            action.setOperation(operation);
            action.setSourceId(remoteProcessGroup.getIdentifier());
            action.setSourceName(remoteProcessGroup.getName());
            action.setSourceType(Component.RemoteProcessGroup);
            action.setComponentDetails(remoteProcessGroupDetails);

            if (actionDetails != null) {
                action.setActionDetails(actionDetails);
            }
        }

        return action;
    }
}
