/*
 * 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.commons.lang3.StringUtils;
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.details.ActionDetails;
import org.apache.nifi.action.details.ConnectDetails;
import org.apache.nifi.action.details.FlowChangeConfigureDetails;
import org.apache.nifi.action.details.FlowChangeConnectDetails;
import org.apache.nifi.connectable.Connectable;
import org.apache.nifi.connectable.Connection;
import org.apache.nifi.connectable.Funnel;
import org.apache.nifi.connectable.Port;
import org.apache.nifi.controller.ProcessorNode;
import org.apache.nifi.flowfile.FlowFilePrioritizer;
import org.apache.nifi.groups.ProcessGroup;
import org.apache.nifi.processor.Relationship;
import org.apache.nifi.remote.RemoteGroupPort;
import org.apache.nifi.remote.TransferDirection;
import org.apache.nifi.web.api.dto.ConnectionDTO;
import org.apache.nifi.web.dao.ConnectionDAO;
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.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

/**
 * Audits relationship creation/removal.
 */
@Service
@Aspect
public class RelationshipAuditor extends NiFiAuditor {

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

    private static final String NAME = "Name";
    private static final String FLOW_FILE_EXPIRATION = "File Expiration";
    private static final String BACK_PRESSURE_OBJECT_THRESHOLD = "Back Pressure Object Threshold";
    private static final String BACK_PRESSURE_DATA_SIZE_THRESHOLD = "Back Pressure Data Size Threshold";
    private static final String PRIORITIZERS = "Prioritizers";

    /**
     * Audits the creation of relationships via createConnection().
     *
     * 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 connection
     * @throws java.lang.Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.ConnectionDAO+) && "
            + "execution(org.apache.nifi.connectable.Connection createConnection(java.lang.String, org.apache.nifi.web.api.dto.ConnectionDTO))")
    public Connection createConnectionAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        // perform the underlying operation
        Connection connection = (Connection) proceedingJoinPoint.proceed();

        // audit the connection creation
        final ConnectDetails connectDetails = createConnectDetails(connection, connection.getRelationships());
        final Action action = generateAuditRecordForConnection(connection, Operation.Connect, connectDetails);

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

        return connection;
    }

    /**
     * Audits the creation and removal of relationships via updateConnection().
     *
     * @param proceedingJoinPoint join point
     * @param connectionDTO dto
     * @param connectionDAO dao
     * @return connection
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.ConnectionDAO+) && "
            + "execution(org.apache.nifi.connectable.Connection updateConnection(org.apache.nifi.web.api.dto.ConnectionDTO)) && "
            + "args(connectionDTO) && "
            + "target(connectionDAO)")
    public Connection updateConnectionAdvice(ProceedingJoinPoint proceedingJoinPoint, ConnectionDTO connectionDTO, ConnectionDAO connectionDAO) throws Throwable {
        // get the previous configuration
        Connection connection = connectionDAO.getConnection(connectionDTO.getId());
        Connectable previousDestination = connection.getDestination();
        Collection<Relationship> previousRelationships = connection.getRelationships();
        Map<String, String> values = extractConfiguredPropertyValues(connection, connectionDTO);

        // perform the underlying operation
        connection = (Connection) proceedingJoinPoint.proceed();

        if (isAuditable()) {
            Collection<Action> actions = new ArrayList<>();
            Map<String, String> updatedValues = extractConfiguredPropertyValues(connection, connectionDTO);

            // get the source
            Connectable source = connection.getSource();

            // get the current target
            Connectable destination = connection.getDestination();

            // determine if a new target was specified in the initial request
            if (destination != null && !previousDestination.getIdentifier().equals(destination.getIdentifier())) {
                // record the removal of all relationships from the previous target
                final ConnectDetails disconnectDetails = createConnectDetails(connection, source, previousRelationships, previousDestination);
                actions.add(generateAuditRecordForConnection(connection, Operation.Disconnect, disconnectDetails));

                // record the addition of all relationships to the new target
                final ConnectDetails connectDetails = createConnectDetails(connection, connection.getRelationships());
                actions.add(generateAuditRecordForConnection(connection, Operation.Connect, connectDetails));
            }

            // audit and relationships added/removed
            Collection<Relationship> newRelationships = connection.getRelationships();

            // identify any relationships that were added
            if (newRelationships != null) {
                List<Relationship> relationshipsToAdd = new ArrayList<>(newRelationships);
                if (previousRelationships != null) {
                    relationshipsToAdd.removeAll(previousRelationships);
                }
                if (!relationshipsToAdd.isEmpty()) {
                    final ConnectDetails connectDetails = createConnectDetails(connection, relationshipsToAdd);
                    actions.add(generateAuditRecordForConnection(connection, Operation.Connect, connectDetails));
                }
            }

            // identify any relationships that were removed
            if (previousRelationships != null) {
                List<Relationship> relationshipsToRemove = new ArrayList<>(previousRelationships);
                if (newRelationships != null) {
                    relationshipsToRemove.removeAll(newRelationships);
                }
                if (!relationshipsToRemove.isEmpty()) {
                    final ConnectDetails connectDetails = createConnectDetails(connection, relationshipsToRemove);
                    actions.add(generateAuditRecordForConnection(connection, Operation.Disconnect, connectDetails));
                }
            }

            // go through each updated value
            Date actionTimestamp = new Date();
            for (String property : updatedValues.keySet()) {
                String newValue = updatedValues.get(property);
                String oldValue = values.get(property);

                // ensure the value is changing
                if (oldValue == null || newValue == null || !newValue.equals(oldValue)) {
                    // create the config details
                    FlowChangeConfigureDetails configurationDetails = new FlowChangeConfigureDetails();
                    configurationDetails.setName(property);
                    configurationDetails.setValue(newValue);
                    configurationDetails.setPreviousValue(oldValue);

                    // create a configuration action
                    final FlowChangeAction configurationAction = createFlowChangeAction();
                    configurationAction.setOperation(Operation.Configure);
                    configurationAction.setTimestamp(actionTimestamp);
                    configurationAction.setSourceId(connection.getIdentifier());
                    configurationAction.setSourceName(connection.getName());
                    configurationAction.setSourceType(Component.Connection);
                    configurationAction.setActionDetails(configurationDetails);
                    actions.add(configurationAction);
                }

            }

            // save the actions
            if (!actions.isEmpty()) {
                saveActions(actions, logger);
            }
        }

        return connection;
    }

    /**
     * Audits the removal of relationships via deleteConnection().
     *
     * @param proceedingJoinPoint join point
     * @param id id
     * @param connectionDAO dao
     * @throws Throwable ex
     */
    @Around("within(org.apache.nifi.web.dao.ConnectionDAO+) && "
            + "execution(void deleteConnection(java.lang.String)) && "
            + "args(id) && "
            + "target(connectionDAO)")
    public void removeConnectionAdvice(ProceedingJoinPoint proceedingJoinPoint, String id, ConnectionDAO connectionDAO) throws Throwable {
        // get the connection before performing the update
        Connection connection = connectionDAO.getConnection(id);

        // perform the underlying operation
        proceedingJoinPoint.proceed();

        // audit the connection creation
        final ConnectDetails connectDetails = createConnectDetails(connection, connection.getRelationships());
        final Action action = generateAuditRecordForConnection(connection, Operation.Disconnect, connectDetails);

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

    public ConnectDetails createConnectDetails(final Connection connection, final Collection<Relationship> relationships) {
        return createConnectDetails(connection, connection.getSource(), relationships, connection.getDestination());
    }

    /**
     * Creates action details for connect/disconnect actions.
     *
     * @param connection connection
     * @param source source
     * @param relationships relationships
     * @param destination destinations
     * @return details
     */
    public ConnectDetails createConnectDetails(final Connection connection, final Connectable source, final Collection<Relationship> relationships, final Connectable destination) {
        final Component sourceType = determineConnectableType(source);
        final Component destiantionType = determineConnectableType(destination);

        // format the relationship names
        Collection<String> relationshipNames = new HashSet<>(connection.getRelationships().size());
        for (final Relationship relationship : relationships) {
            relationshipNames.add(relationship.getName());
        }
        final String formattedRelationships = relationshipNames.isEmpty() ? StringUtils.EMPTY : StringUtils.join(relationshipNames, ", ");

        // create the connect details
        final FlowChangeConnectDetails connectDetails = new FlowChangeConnectDetails();
        connectDetails.setSourceId(source.getIdentifier());
        connectDetails.setSourceName(source.getName());
        connectDetails.setSourceType(sourceType);
        connectDetails.setRelationship(formattedRelationships);
        connectDetails.setDestinationId(destination.getIdentifier());
        connectDetails.setDestinationName(destination.getName());
        connectDetails.setDestinationType(destiantionType);
        return connectDetails;
    }

    /**
     * Extracts configured settings from the specified connection only if they have also been specified in the connectionDTO.
     *
     * @param connection connection
     * @param connectionDTO dto
     * @return properties
     */
    private Map<String, String> extractConfiguredPropertyValues(Connection connection, ConnectionDTO connectionDTO) {
        Map<String, String> values = new HashMap<>();

        if (connectionDTO.getName() != null) {
            values.put(NAME, connection.getName());
        }
        if (connectionDTO.getFlowFileExpiration() != null) {
            values.put(FLOW_FILE_EXPIRATION, String.valueOf(connection.getFlowFileQueue().getFlowFileExpiration()));
        }
        if (connectionDTO.getBackPressureObjectThreshold() != null) {
            values.put(BACK_PRESSURE_OBJECT_THRESHOLD, String.valueOf(connection.getFlowFileQueue().getBackPressureObjectThreshold()));
        }
        if (connectionDTO.getBackPressureDataSizeThreshold() != null) {
            values.put(BACK_PRESSURE_DATA_SIZE_THRESHOLD, String.valueOf(connection.getFlowFileQueue().getBackPressureDataSizeThreshold()));
        }
        if (connectionDTO.getPrioritizers() != null) {
            List<String> prioritizers = new ArrayList<>();
            for (FlowFilePrioritizer prioritizer : connection.getFlowFileQueue().getPriorities()) {
                prioritizers.add(prioritizer.getClass().getCanonicalName());
            }
            values.put(PRIORITIZERS, StringUtils.join(prioritizers, ", "));
        }

        return values;
    }

    /**
     * Generates the audit records for the specified connection.
     *
     * @param connection connection
     * @param operation operation
     * @return action
     */
    public Action generateAuditRecordForConnection(Connection connection, Operation operation) {
        return generateAuditRecordForConnection(connection, operation, null);
    }

    /**
     * Generates the audit records for the specified connection.
     *
     * @param connection connection
     * @param operation operation
     * @param actionDetails details
     * @return action
     */
    public Action generateAuditRecordForConnection(Connection connection, Operation operation, ActionDetails actionDetails) {
        FlowChangeAction action = null;

        if (isAuditable()) {
            // determine the source details
            final String connectionId = connection.getIdentifier();

            String connectionName = connection.getName();
            if (StringUtils.isBlank(connectionName)) {
                Collection<String> relationshipNames = new HashSet<>(connection.getRelationships().size());
                for (final Relationship relationship : connection.getRelationships()) {
                    relationshipNames.add(relationship.getName());
                }
                connectionName = StringUtils.join(relationshipNames, ", ");
            }

            // go through each relationship added
            Date actionTimestamp = new Date();

            // create a new relationship action
            action = createFlowChangeAction();
            action.setOperation(operation);
            action.setTimestamp(actionTimestamp);
            action.setSourceId(connectionId);
            action.setSourceName(connectionName);
            action.setSourceType(Component.Connection);

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

        return action;
    }

    /**
     * Determines the type of component the specified connectable is.
     */
    private Component determineConnectableType(Connectable connectable) {
        String sourceId = connectable.getIdentifier();
        Component componentType = Component.Controller;
        if (connectable instanceof ProcessorNode) {
            componentType = Component.Processor;
        } else if (connectable instanceof RemoteGroupPort) {
            final RemoteGroupPort remoteGroupPort = (RemoteGroupPort) connectable;
            if (TransferDirection.RECEIVE.equals(remoteGroupPort.getTransferDirection())) {
                if (remoteGroupPort.getRemoteProcessGroup() == null) {
                    componentType = Component.InputPort;
                } else {
                    componentType = Component.OutputPort;
                }
            } else {
                if (remoteGroupPort.getRemoteProcessGroup() == null) {
                    componentType = Component.OutputPort;
                } else {
                    componentType = Component.InputPort;
                }
            }
        } else if (connectable instanceof Port) {
            ProcessGroup processGroup = connectable.getProcessGroup();
            if (processGroup.getInputPort(sourceId) != null) {
                componentType = Component.InputPort;
            } else if (processGroup.getOutputPort(sourceId) != null) {
                componentType = Component.OutputPort;
            }
        } else if (connectable instanceof Funnel) {
            componentType = Component.Funnel;
        }
        return componentType;
    }

}
