/*
 * 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.fingerprint;

import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.bundle.BundleCoordinate;
import org.apache.nifi.components.ConfigurableComponent;
import org.apache.nifi.components.PropertyDescriptor;
import org.apache.nifi.controller.FlowController;
import org.apache.nifi.controller.serialization.FlowEncodingVersion;
import org.apache.nifi.controller.serialization.FlowFromDOMFactory;
import org.apache.nifi.encrypt.PropertyEncryptor;
import org.apache.nifi.encrypt.SensitiveValueEncoder;
import org.apache.nifi.nar.ExtensionManager;
import org.apache.nifi.util.BundleUtils;
import org.apache.nifi.util.DomUtils;
import org.apache.nifi.util.LoggingXmlParserErrorHandler;
import org.apache.nifi.web.api.dto.BundleDTO;
import org.apache.nifi.web.api.dto.ControllerServiceDTO;
import org.apache.nifi.web.api.dto.FlowRegistryClientDTO;
import org.apache.nifi.web.api.dto.ParameterProviderDTO;
import org.apache.nifi.web.api.dto.ReportingTaskDTO;
import org.apache.nifi.xml.processing.ProcessingException;
import org.apache.nifi.xml.processing.parsers.StandardDocumentProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.ErrorHandler;

import javax.xml.XMLConstants;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.stream.Stream;

/**
 * <p>Creates a fingerprint of a flow.xml. The order of elements or attributes in the flow.xml does not influence the fingerprint generation.
 *
 * <p>Only items in the flow.xml that influence the processing of data are incorporated into the fingerprint.
 * Examples of items involved in the fingerprint are: processor IDs, processor relationships, and processor properties.
 * Examples of items not involved in the fingerprint are: items in the processor "comments" tabs, position information, flow controller settings, and counters.
 *
 * <p>The determination for making items into the fingerprint is whether we can
 * easily change the setting in order to inherit the cluster's flow.
 * For example, if the component has to be stopped to apply the change and started again,
 * then the item should be included in a fingerprint.
 */
public class FingerprintFactory {

    /*
     * Developer Note: This class should be changed with care and coordinated
     * with all classes that use fingerprinting.  Improper coordination may
     * lead to orphaning flow files, especially when flows are reloaded in a
     * clustered environment.
     */
    // no fingerprint value
    public static final String NO_VALUE = "NO_VALUE";

    static final String FLOW_CONFIG_XSD = "/FlowConfiguration.xsd";
    private static final String ENCRYPTED_VALUE_PREFIX = "enc{";
    private static final String ENCRYPTED_VALUE_SUFFIX = "}";
    private final PropertyEncryptor encryptor;
    private final Schema schema;
    private final ExtensionManager extensionManager;
    private final SensitiveValueEncoder sensitiveValueEncoder;

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

    public FingerprintFactory(final PropertyEncryptor encryptor, final ExtensionManager extensionManager, final SensitiveValueEncoder sensitiveValueEncoder) {
        this.encryptor = encryptor;
        this.extensionManager = extensionManager;
        this.sensitiveValueEncoder = sensitiveValueEncoder;

        final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
        try {
            schema = schemaFactory.newSchema(FingerprintFactory.class.getResource(FLOW_CONFIG_XSD));
        } catch (final Exception e) {
            throw new RuntimeException("Failed to parse schema for file flow configuration.", e);
        }
    }

    /**
     * Creates a fingerprint of a flow. The order of elements or attributes in the flow does not influence the fingerprint generation.
     * This method does not accept a FlowController, which means that Processors cannot be created in order to verify default property
     * values, etc. As a result, if Flow A and Flow B are fingerprinted and Flow B, for instance, contains a property with a default value
     * that is not present in Flow A, then the two will have different fingerprints.
     *
     * @param flowBytes the flow represented as bytes
     * @return a generated fingerprint
     * @throws FingerprintException if the fingerprint failed to be generated
     */
    public synchronized String createFingerprint(final byte[] flowBytes) throws FingerprintException {
        return createFingerprint(flowBytes, null);
    }

    /**
     * Creates a fingerprint of a flow. The order of elements or attributes in the flow does not influence the fingerprint generation.
     *
     * @param flowBytes  the flow represented as bytes
     * @param controller the controller
     * @return a generated fingerprint
     * @throws FingerprintException if the fingerprint failed to be generated
     */
    public synchronized String createFingerprint(final byte[] flowBytes, final FlowController controller) throws FingerprintException {
        return createFingerprint(parseFlow(flowBytes), controller);
    }

    /**
     * Creates a fingerprint from an XML document representing the flow.xml.
     *
     * @param flowDoc the DOM
     * @return the fingerprint
     */
    public synchronized String createFingerprint(final Document flowDoc, final FlowController controller) {
        if (flowDoc == null) {
            return "";
        }

        // builder to hold fingerprint state
        final StringBuilder fingerprintBuilder = new StringBuilder();

        // add flow controller fingerprint
        final Element flowControllerElem = flowDoc.getDocumentElement();
        if (flowControllerElem == null) {
            logger.warn("Unable to create fingerprint because no 'flowController' element found in XML.");
            return "";
        }

        final FlowEncodingVersion encodingVersion = FlowEncodingVersion.parse(flowControllerElem);

        addFlowControllerFingerprint(fingerprintBuilder, flowControllerElem, controller, encodingVersion);

        return fingerprintBuilder.toString();
    }

    /**
     * Parse the given flow.xml bytes into a Document instance.
     *
     * @param flow a flow
     * @return the DOM
     * @throws FingerprintException if the flow could not be parsed
     */
    private Document parseFlow(final byte[] flow) throws FingerprintException {
        if (flow == null || flow.length == 0) {
            return null;
        }

        try {
            final ErrorHandler errorHandler = new LoggingXmlParserErrorHandler("Flow Configuration", logger);
            final StandardDocumentProvider documentProvider = new StandardDocumentProvider();
            documentProvider.setSchema(schema);
            documentProvider.setNamespaceAware(true);
            documentProvider.setErrorHandler(errorHandler);

            return documentProvider.parse(new ByteArrayInputStream(flow));
        } catch (final ProcessingException e) {
            throw new FingerprintException("Flow Parsing failed", e);
        }
    }

    private StringBuilder addFlowControllerFingerprint(final StringBuilder builder, final Element flowControllerElem, final FlowController controller, final FlowEncodingVersion encodingVersion) {
        // registries
        final Element registriesElement = DomUtils.getChild(flowControllerElem, "registries");
        if (registriesElement == null) {
            builder.append("NO_VALUE");
        } else {
            final List<Element> flowRegistryElems = DomUtils.getChildElementsByTagName(registriesElement, "flowRegistry");
            if (flowRegistryElems.isEmpty()) {
                builder.append("NO_VALUE");
            } else {
                final List<FlowRegistryClientDTO> registryClientDtos = new ArrayList<>();
                for (final Element flowRegistryElement : flowRegistryElems) {
                    registryClientDtos.add(FlowFromDOMFactory.getFlowRegistryClient(flowRegistryElement, encryptor, encodingVersion));
                }

                registryClientDtos.sort(Comparator.comparing(FlowRegistryClientDTO::getId));

                for (final FlowRegistryClientDTO registryClientDto : registryClientDtos) {
                    addFlowRegistryFingerprint(builder, registryClientDto);
                }
            }
        }

        final Element contextsElement = DomUtils.getChild(flowControllerElem, "parameterContexts");
        if (contextsElement == null) {
            builder.append("NO_PARAMETER_CONTEXTS");
        } else {
            final List<Element> parameterContextElements = DomUtils.getChildElementsByTagName(contextsElement, "parameterContext");
            if (parameterContextElements.isEmpty()) {
                builder.append("NO_PARAMETER_CONTEXTS");
            } else {
                orderByChildElement(parameterContextElements, "id");

                for (final Element parameterContextElement : parameterContextElements) {
                    addParameterContext(builder, parameterContextElement);
                }
            }
        }

        // root group
        final Element rootGroupElem = (Element) DomUtils.getChildNodesByTagName(flowControllerElem, "rootGroup").item(0);
        addProcessGroupFingerprint(builder, rootGroupElem, encodingVersion);

        final Element controllerServicesElem = DomUtils.getChild(flowControllerElem, "controllerServices");
        if (controllerServicesElem != null) {
            final List<ControllerServiceDTO> serviceDtos = new ArrayList<>();
            for (final Element serviceElem : DomUtils.getChildElementsByTagName(controllerServicesElem, "controllerService")) {
                final ControllerServiceDTO dto = FlowFromDOMFactory.getControllerService(serviceElem, encryptor, encodingVersion);
                serviceDtos.add(dto);
            }

            Collections.sort(serviceDtos, new Comparator<ControllerServiceDTO>() {
                @Override
                public int compare(final ControllerServiceDTO o1, final ControllerServiceDTO o2) {
                    if (o1 == null && o2 == null) {
                        return 0;
                    }
                    if (o1 == null && o2 != null) {
                        return 1;
                    }
                    if (o1 != null && o2 == null) {
                        return -1;
                    }

                    return o1.getId().compareTo(o2.getId());
                }
            });

            for (final ControllerServiceDTO dto : serviceDtos) {
                addControllerServiceFingerprint(builder, dto);
            }
        }

        final Element reportingTasksElem = DomUtils.getChild(flowControllerElem, "reportingTasks");
        if (reportingTasksElem != null) {
            final List<ReportingTaskDTO> reportingTaskDtos = new ArrayList<>();
            for (final Element taskElem : DomUtils.getChildElementsByTagName(reportingTasksElem, "reportingTask")) {
                final ReportingTaskDTO dto = FlowFromDOMFactory.getReportingTask(taskElem, encryptor, encodingVersion);
                reportingTaskDtos.add(dto);
            }

            Collections.sort(reportingTaskDtos, new Comparator<ReportingTaskDTO>() {
                @Override
                public int compare(final ReportingTaskDTO o1, final ReportingTaskDTO o2) {
                    if (o1 == null && o2 == null) {
                        return 0;
                    }
                    if (o1 == null && o2 != null) {
                        return 1;
                    }
                    if (o1 != null && o2 == null) {
                        return -1;
                    }

                    return o1.getId().compareTo(o2.getId());
                }
            });

            for (final ReportingTaskDTO dto : reportingTaskDtos) {
                addReportingTaskFingerprint(builder, dto);
            }
        }

        final Element parameterProvidersElem = DomUtils.getChild(flowControllerElem, "parameterProviders");
        if (parameterProvidersElem != null) {
            final List<ParameterProviderDTO> parameterProviderDtos = new ArrayList<>();
            for (final Element taskElem : DomUtils.getChildElementsByTagName(parameterProvidersElem, "parameterProvider")) {
                final ParameterProviderDTO dto = FlowFromDOMFactory.getParameterProvider(taskElem, encryptor, encodingVersion);
                parameterProviderDtos.add(dto);
            }

            Collections.sort(parameterProviderDtos, Comparator.comparing(ParameterProviderDTO::getId));

            for (final ParameterProviderDTO dto : parameterProviderDtos) {
                addParameterProviderFingerprint(builder, dto);
            }
        }

        return builder;
    }

    private void orderByChildElement(final List<Element> toSort, final String childTagName) {
        toSort.sort((a, b) -> {
            final String valueA = DomUtils.getChildText(a, childTagName);
            final String valueB = DomUtils.getChildText(b, childTagName);
            return valueA.compareTo(valueB);
        });
    }

    private StringBuilder addParameterContext(final StringBuilder builder, final Element parameterContextElement) {
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(parameterContextElement, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(parameterContextElement, "name"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(parameterContextElement, "description"));

        final List<Element> parameterElements = DomUtils.getChildElementsByTagName(parameterContextElement, "parameter");
        if (parameterElements == null || parameterElements.isEmpty()) {
            builder.append("NO_PARAMETERS");
        } else {
            orderByChildElement(parameterElements, "name");

            for (final Element parameterElement : parameterElements) {
                addParameter(builder, parameterElement);
            }
        }

        final List<Element> inheritedParameterContexts = DomUtils.getChildElementsByTagName(parameterContextElement, "inheritedParameterContextId");
        if (inheritedParameterContexts == null || inheritedParameterContexts.isEmpty()) {
            builder.append("NO_INHERITED_PARAMETER_CONTEXT_IDS");
        } else {
            for (final Element inheritedParameterContextId : inheritedParameterContexts) {
                builder.append(inheritedParameterContextId.getTextContent());
            }
        }
        final String parameterProviderId = DomUtils.getChildText(parameterContextElement, "parameterProviderId");
        builder.append(parameterProviderId == null ? "NO_PARAMETER_PROVIDER_ID" : parameterProviderId);
        final String parameterGroupName = DomUtils.getChildText(parameterContextElement, "parameterGroupName");
        builder.append(parameterGroupName == null ? "NO_PARAMETER_GROUP_NAME" : parameterGroupName);
        final String isSynchronized = DomUtils.getChildText(parameterContextElement, "isSynchronized");
        builder.append(isSynchronized == null ? "NO_PARAMETER_IS_SYNCHRONIZED" : isSynchronized);

        return builder;
    }

    private void addParameter(final StringBuilder builder, final Element parameterElement) {
        Stream.of("name", "description", "sensitive", "provided").forEach(elementName -> appendFirstValue(builder, DomUtils.getChildNodesByTagName(parameterElement, elementName)));

        final String value = DomUtils.getChildText(parameterElement, "value");
        if (value == null) {
            builder.append("NO_VALUE");
            return;
        }

        // append value
        if (isEncrypted(value)) {
            // Get a secure, deterministic, loggable representation of this value
            builder.append(getLoggableRepresentationOfSensitiveValue(value));
        } else {
            builder.append(getValue(value, NO_VALUE));
        }

    }

    private void addFlowRegistryFingerprint(final StringBuilder builder, final FlowRegistryClientDTO dto) {
        builder.append(dto.getId());
        builder.append(dto.getName());
        builder.append(dto.getDeprecated());
        builder.append(dto.getUri());

        builder.append(dto.getType());
        addBundleFingerprint(builder, dto.getBundle());

        // get the temp instance of the FlowRegistryClient so that we know the default property values
        final BundleCoordinate coordinate = getCoordinate(dto.getType(), dto.getBundle());
        final ConfigurableComponent configurableComponent = extensionManager.getTempComponent(dto.getType(), coordinate);
        if (configurableComponent == null) {
            logger.warn("Unable to get FlowRegistryClient of type {}; its default properties will be fingerprinted instead of being ignored.", dto.getType());
        }
        addPropertiesFingerprint(builder, configurableComponent, dto.getProperties());
    }

    StringBuilder addProcessGroupFingerprint(final StringBuilder builder, final Element processGroupElem, final FlowEncodingVersion encodingVersion) throws FingerprintException {
        // id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "versionedComponentId"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "parameterContextId"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "flowfileConcurrency"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "flowfileOutboundPolicy"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "defaultFlowFileExpiration"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "defaultBackPressureObjectThreshold"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "defaultBackPressureDataSizeThreshold"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processGroupElem, "logFileSuffix"));

        final Element versionControlInfo = DomUtils.getChild(processGroupElem, "versionControlInformation");
        if (versionControlInfo == null) {
            builder.append("NO_VERSION_CONTROL_INFORMATION");
        } else {
            appendFirstValue(builder, DomUtils.getChildNodesByTagName(versionControlInfo, "registryId"));
            appendFirstValue(builder, DomUtils.getChildNodesByTagName(versionControlInfo, "bucketId"));
            appendFirstValue(builder, DomUtils.getChildNodesByTagName(versionControlInfo, "flowId"));
            appendFirstValue(builder, DomUtils.getChildNodesByTagName(versionControlInfo, "version"));
        }

        // processors
        final List<Element> processorElems = DomUtils.getChildElementsByTagName(processGroupElem, "processor");
        processorElems.sort(getIdsComparator());
        for (final Element processorElem : processorElems) {
            addFlowFileProcessorFingerprint(builder, processorElem);
        }

        // input ports
        final NodeList inputPortElems = DomUtils.getChildNodesByTagName(processGroupElem, "inputPort");
        final List<Element> sortedInputPortElems = sortElements(inputPortElems, getIdsComparator());
        for (final Element inputPortElem : sortedInputPortElems) {
            addPortFingerprint(builder, inputPortElem);
        }

        // labels
        final NodeList labelElems = DomUtils.getChildNodesByTagName(processGroupElem, "label");
        final List<Element> sortedLabels = sortElements(labelElems, getIdsComparator());
        for (final Element labelElem : sortedLabels) {
            addLabelFingerprint(builder, labelElem);
        }

        // output ports
        final NodeList outputPortElems = DomUtils.getChildNodesByTagName(processGroupElem, "outputPort");
        final List<Element> sortedOutputPortElems = sortElements(outputPortElems, getIdsComparator());
        for (final Element outputPortElem : sortedOutputPortElems) {
            addPortFingerprint(builder, outputPortElem);
        }

        // process groups
        final NodeList nestedProcessGroupElems = DomUtils.getChildNodesByTagName(processGroupElem, "processGroup");
        final List<Element> sortedNestedProcessGroupElems = sortElements(nestedProcessGroupElems, getIdsComparator());
        for (final Element nestedProcessGroupElem : sortedNestedProcessGroupElems) {
            addProcessGroupFingerprint(builder, nestedProcessGroupElem, encodingVersion);
        }

        // remote process groups
        final NodeList remoteProcessGroupElems = DomUtils.getChildNodesByTagName(processGroupElem, "remoteProcessGroup");
        final List<Element> sortedRemoteProcessGroupElems = sortElements(remoteProcessGroupElems, getIdsComparator());
        for (final Element remoteProcessGroupElem : sortedRemoteProcessGroupElems) {
            addRemoteProcessGroupFingerprint(builder, remoteProcessGroupElem);
        }

        // connections
        final NodeList connectionElems = DomUtils.getChildNodesByTagName(processGroupElem, "connection");
        final List<Element> sortedConnectionElems = sortElements(connectionElems, getIdsComparator());
        for (final Element connectionElem : sortedConnectionElems) {
            addConnectionFingerprint(builder, connectionElem);
        }

        // funnel
        final NodeList funnelElems = DomUtils.getChildNodesByTagName(processGroupElem, "funnel");
        final List<Element> sortedFunnelElems = sortElements(funnelElems, getIdsComparator());
        for (final Element funnelElem : sortedFunnelElems) {
            addFunnelFingerprint(builder, funnelElem);
        }

        final NodeList controllerServiceElems = DomUtils.getChildNodesByTagName(processGroupElem, "controllerService");
        final List<Element> sortedControllerServiceElems = sortElements(controllerServiceElems, getIdsComparator());
        for (final Element controllerServiceElem : sortedControllerServiceElems) {
            final ControllerServiceDTO dto = FlowFromDOMFactory.getControllerService(controllerServiceElem, encryptor, encodingVersion);
            addControllerServiceFingerprint(builder, dto);
        }

        // add variables
        final NodeList variableElems = DomUtils.getChildNodesByTagName(processGroupElem, "variable");
        final List<Element> sortedVarList = sortElements(variableElems, getVariableNameComparator());
        for (final Element varElem : sortedVarList) {
            addVariableFingerprint(builder, varElem);
        }

        return builder;
    }

    private void addVariableFingerprint(final StringBuilder builder, final Element variableElement) {
        final String variableName = variableElement.getAttribute("name");
        final String variableValue = variableElement.getAttribute("value");
        builder.append(variableName).append("=").append(variableValue);
    }

    private StringBuilder addFlowFileProcessorFingerprint(final StringBuilder builder, final Element processorElem) throws FingerprintException {
        // id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "versionedComponentId"));
        // class
        final NodeList childNodes = DomUtils.getChildNodesByTagName(processorElem, "class");
        final String className = childNodes.item(0).getTextContent();
        appendFirstValue(builder, childNodes);
        // annotation data
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "annotationData"));

        // get the bundle details if possible
        final BundleDTO bundle = FlowFromDOMFactory.getBundle(DomUtils.getChild(processorElem, "bundle"));
        addBundleFingerprint(builder, bundle);

        // max concurrent tasks
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "maxConcurrentTasks"));
        // scheduling period
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "schedulingPeriod"));
        // penalization period
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "penalizationPeriod"));
        // yield period
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "yieldPeriod"));
        // bulletin level
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "bulletinLevel"));
        // loss tolerant
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "lossTolerant"));
        // scheduling strategy
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "schedulingStrategy"));
        // execution node
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "executionNode"));
        // run duration nanos
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "runDurationNanos"));
        // retry count
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "retryCount"));
        // backoff mechanism
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "backoffMechanism"));
        // max backoff period
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(processorElem, "maxBackoffPeriod"));

        // get the temp instance of the Processor so that we know the default property values
        final BundleCoordinate coordinate = getCoordinate(className, bundle);
        final ConfigurableComponent configurableComponent = extensionManager.getTempComponent(className, coordinate);
        if (configurableComponent == null) {
            logger.warn("Unable to get Processor of type {}; its default properties will be fingerprinted instead of being ignored.", className);
        }

        // properties
        final NodeList propertyElems = DomUtils.getChildNodesByTagName(processorElem, "property");
        final List<Element> sortedPropertyElems = sortElements(propertyElems, getProcessorPropertiesComparator());
        for (final Element propertyElem : sortedPropertyElems) {
            final String propName = DomUtils.getChildElementsByTagName(propertyElem, "name").get(0).getTextContent();
            String propValue = getFirstValue(DomUtils.getChildNodesByTagName(propertyElem, "value"), null);
            addPropertyFingerprint(builder, configurableComponent, propName, propValue);
        }

        final NodeList autoTerminateElems = DomUtils.getChildNodesByTagName(processorElem, "autoTerminatedRelationship");
        final List<Element> sortedAutoTerminateElems = sortElements(autoTerminateElems, getElementTextComparator());
        for (final Element autoTerminateElem : sortedAutoTerminateElems) {
            builder.append(autoTerminateElem.getTextContent());
        }

        final NodeList retriedRelationshipsElems = DomUtils.getChildNodesByTagName(processorElem, "retriedRelationships");
        final List<Element> sortedRetriedRelationshipsElems = sortElements(retriedRelationshipsElems, getElementTextComparator());
        for (final Element retriedRelationshipElem : sortedRetriedRelationshipsElems) {
            builder.append(retriedRelationshipElem.getTextContent());
        }

        return builder;
    }

    private StringBuilder addPropertyFingerprint(final StringBuilder builder, final ConfigurableComponent component, final String propName, final String propValue) throws FingerprintException {
        // If we have a component to use, first determine if the value given is the default value for the specified property.
        // If so, we do not add the property to the fingerprint.
        // We do this because if a component is updated to add a new property, whenever we connect to the cluster, we have issues because
        // the Cluster Coordinator's flow comes from disk, where the flow.xml doesn't have the new property but our FlowController does have the new property.
        // This causes the fingerprints not to match. As a result, we just ignore default values, and this resolves the issue.

        if (component != null) {
            final PropertyDescriptor descriptor = component.getPropertyDescriptor(propName);
            if (descriptor != null && propValue != null && propValue.equals(descriptor.getDefaultValue())) {
                return builder;
            }
        }

        // check if there is a value
        if (propValue == null) {
            return builder;
        }

        // append name
        builder.append(propName).append("=");

        // append value
        if (isEncrypted(propValue)) {
            // Get a secure, deterministic, loggable representation of this value
            builder.append(getLoggableRepresentationOfSensitiveValue(propValue));
        } else {
            builder.append(getValue(propValue, NO_VALUE));
        }

        return builder;
    }

    /**
     * Returns a securely-derived, deterministic value from the provided encrypted property
     * value. This is because the flow fingerprint is displayed in the log if NiFi has
     * trouble inheriting a flow, so the sensitive value should not be disclosed through the
     * log. However, the equality or difference of the sensitive value can influence in the
     * inheritability of the flow, so it cannot be ignored completely.
     * <p>
     * The specific derivation process is unimportant as long as it is a salted,
     * cryptographically-secure hash function with an iteration cost sufficient for password
     * storage in other applications.
     *
     * @param encryptedPropertyValue the encrypted property value
     * @return a deterministic string value which represents this input but is safe to print in a log
     */
    private String getLoggableRepresentationOfSensitiveValue(String encryptedPropertyValue) {
        final String plaintextValue = decrypt(encryptedPropertyValue);
        return sensitiveValueEncoder.getEncoded(plaintextValue);
    }

    private StringBuilder addPortFingerprint(final StringBuilder builder, final Element portElem) throws FingerprintException {
        // id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(portElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(portElem, "versionedComponentId"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(portElem, "name"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(portElem, "allowRemoteAccess"));

        // user access control
        final NodeList userAccessControlNodeList = DomUtils.getChildNodesByTagName(portElem, "userAccessControl");
        if (userAccessControlNodeList == null || userAccessControlNodeList.getLength() == 0) {
            builder.append("NO_USER_ACCESS_CONTROL");
        } else {
            final List<String> sortedAccessControl = new ArrayList<>();
            for (int i = 0; i < userAccessControlNodeList.getLength(); i++) {
                sortedAccessControl.add(userAccessControlNodeList.item(i).getTextContent());
            }
            Collections.sort(sortedAccessControl);
            for (final String user : sortedAccessControl) {
                builder.append(user);
            }
        }

        // group access control
        final NodeList groupAccessControlNodeList = DomUtils.getChildNodesByTagName(portElem, "groupAccessControl");
        if (groupAccessControlNodeList == null || groupAccessControlNodeList.getLength() == 0) {
            builder.append("NO_GROUP_ACCESS_CONTROL");
        } else {
            final List<String> sortedAccessControl = new ArrayList<>();
            for (int i = 0; i < groupAccessControlNodeList.getLength(); i++) {
                sortedAccessControl.add(groupAccessControlNodeList.item(i).getTextContent());
            }

            Collections.sort(sortedAccessControl);
            for (final String user : sortedAccessControl) {
                builder.append(user);
            }
        }

        return builder;
    }

    private StringBuilder addLabelFingerprint(final StringBuilder builder, final Element labelElem) {
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(labelElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(labelElem, "versionedComponentId"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(labelElem, "value"));
        return builder;
    }

    private StringBuilder addRemoteProcessGroupFingerprint(final StringBuilder builder, final Element remoteProcessGroupElem) throws FingerprintException {

        for (String tagName : new String[]{"id", "versionedComponentId", "urls", "networkInterface", "timeout", "yieldPeriod",
                "transportProtocol", "proxyHost", "proxyPort", "proxyUser", "proxyPassword"}) {
            final String value = getFirstValue(DomUtils.getChildNodesByTagName(remoteProcessGroupElem, tagName));
            if (isEncrypted(value)) {
                builder.append(getLoggableRepresentationOfSensitiveValue(value));
            } else {
                builder.append(value);
            }
        }

        final NodeList inputPortList = DomUtils.getChildNodesByTagName(remoteProcessGroupElem, "inputPort");
        final NodeList outputPortList = DomUtils.getChildNodesByTagName(remoteProcessGroupElem, "outputPort");

        final Comparator<Element> portComparator = new Comparator<Element>() {
            @Override
            public int compare(final Element o1, final Element o2) {
                if (o1 == null && o2 == null) {
                    return 0;
                }
                if (o1 == null) {
                    return 1;
                }
                if (o2 == null) {
                    return -1;
                }

                NodeList nameList1 = DomUtils.getChildNodesByTagName(o1, "name");
                NodeList nameList2 = DomUtils.getChildNodesByTagName(o2, "name");

                if (nameList1.getLength() == 0 && nameList2.getLength() == 0) {
                    return 0;
                }
                if (nameList1.getLength() == 0) {
                    return 1;
                }
                if (nameList2.getLength() == 0) {
                    return -1;
                }

                return nameList1.item(0).getTextContent().compareTo(nameList2.item(0).getTextContent());
            }
        };

        final List<Element> sortedInputPorts = new ArrayList<>(inputPortList.getLength());
        for (int i = 0; i < inputPortList.getLength(); i++) {
            sortedInputPorts.add((Element) inputPortList.item(i));
        }
        Collections.sort(sortedInputPorts, portComparator);

        final List<Element> sortedOutputPorts = new ArrayList<>(outputPortList.getLength());
        for (int i = 0; i < outputPortList.getLength(); i++) {
            sortedOutputPorts.add((Element) outputPortList.item(i));
        }
        Collections.sort(sortedOutputPorts, portComparator);

        for (final Element inputPortElement : sortedInputPorts) {
            addRemoteGroupPortFingerprint(builder, inputPortElement);
        }

        for (final Element outputPortElement : sortedOutputPorts) {
            addRemoteGroupPortFingerprint(builder, outputPortElement);
        }

        return builder;
    }

    private StringBuilder addRemoteGroupPortFingerprint(final StringBuilder builder, final Element remoteGroupPortElement) {
        for (final String childName : new String[]{"id", "targetId", "versionedComponentId", "maxConcurrentTasks", "useCompression", "batchCount", "batchSize", "batchDuration"}) {
            appendFirstValue(builder, DomUtils.getChildNodesByTagName(remoteGroupPortElement, childName));
        }

        return builder;
    }

    private StringBuilder addConnectionFingerprint(final StringBuilder builder, final Element connectionElem) throws FingerprintException {
        // id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "versionedComponentId"));
        // source id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "sourceId"));
        // source group id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "sourceGroupId"));
        // source type
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "sourceType"));
        // destination id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "destinationId"));
        // destination group id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "destinationGroupId"));
        // destination type
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "destinationType"));

        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "name"));

        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "loadBalanceStrategy"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "partitioningAttribute"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(connectionElem, "loadBalanceCompression"));

        // relationships
        final NodeList relationshipElems = DomUtils.getChildNodesByTagName(connectionElem, "relationship");
        final List<Element> sortedRelationshipElems = sortElements(relationshipElems, getElementTextComparator());
        for (final Element relationshipElem : sortedRelationshipElems) {
            builder.append(getValue(relationshipElem, NO_VALUE));
        }

        return builder;
    }

    private StringBuilder addFunnelFingerprint(final StringBuilder builder, final Element funnelElem) throws FingerprintException {
        // id
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(funnelElem, "id"));
        appendFirstValue(builder, DomUtils.getChildNodesByTagName(funnelElem, "versionedComponentId"));
        return builder;
    }

    private void addControllerServiceFingerprint(final StringBuilder builder, final ControllerServiceDTO dto) {
        builder.append(dto.getId());
        builder.append(dto.getVersionedComponentId());
        builder.append(dto.getType());
        builder.append(dto.getName());

        addBundleFingerprint(builder, dto.getBundle());

        builder.append(dto.getComments());
        builder.append(dto.getBulletinLevel());
        builder.append(dto.getAnnotationData());
        builder.append(dto.getState());

        // get the temp instance of the ControllerService so that we know the default property values
        final BundleCoordinate coordinate = getCoordinate(dto.getType(), dto.getBundle());
        final ConfigurableComponent configurableComponent = extensionManager.getTempComponent(dto.getType(), coordinate);
        if (configurableComponent == null) {
            logger.warn("Unable to get ControllerService of type {}; its default properties will be fingerprinted instead of being ignored.", dto.getType());
        }

        addPropertiesFingerprint(builder, configurableComponent, dto.getProperties());
    }

    private void addPropertiesFingerprint(final StringBuilder builder, final ConfigurableComponent component, final Map<String, String> properties) {
        if (properties == null) {
            builder.append("NO_PROPERTIES");
        } else {
            final SortedMap<String, String> sortedProps = new TreeMap<>(properties);
            for (final Map.Entry<String, String> entry : sortedProps.entrySet()) {
                addPropertyFingerprint(builder, component, entry.getKey(), entry.getValue());
            }
        }
    }

    private void addBundleFingerprint(final StringBuilder builder, final BundleDTO bundle) {
        if (bundle != null) {
            builder.append(bundle.getGroup());
            builder.append(bundle.getArtifact());
            builder.append(bundle.getVersion());
        } else {
            builder.append("MISSING_BUNDLE");
        }
    }

    private BundleCoordinate getCoordinate(final String type, final BundleDTO dto) {
        BundleCoordinate coordinate;
        try {
            coordinate = BundleUtils.getCompatibleBundle(extensionManager, type, dto);
        } catch (final IllegalStateException e) {
            if (dto == null) {
                coordinate = BundleCoordinate.UNKNOWN_COORDINATE;
            } else {
                coordinate = new BundleCoordinate(dto.getGroup(), dto.getArtifact(), dto.getVersion());
            }
        }
        return coordinate;
    }

    private void addReportingTaskFingerprint(final StringBuilder builder, final ReportingTaskDTO dto) {
        builder.append(dto.getId());
        builder.append(dto.getType());
        builder.append(dto.getName());

        addBundleFingerprint(builder, dto.getBundle());

        builder.append(dto.getComments());
        builder.append(dto.getSchedulingPeriod());
        builder.append(dto.getSchedulingStrategy());
        builder.append(dto.getAnnotationData());

        // get the temp instance of the ReportingTask so that we know the default property values
        final BundleCoordinate coordinate = getCoordinate(dto.getType(), dto.getBundle());
        final ConfigurableComponent configurableComponent = extensionManager.getTempComponent(dto.getType(), coordinate);
        if (configurableComponent == null) {
            logger.warn("Unable to get ReportingTask of type {}; its default properties will be fingerprinted instead of being ignored.", dto.getType());
        }

        addPropertiesFingerprint(builder, configurableComponent, dto.getProperties());
    }

    private void addParameterProviderFingerprint(final StringBuilder builder, final ParameterProviderDTO dto) {
        builder.append(dto.getId());
        builder.append(dto.getType());
        builder.append(dto.getName());

        addBundleFingerprint(builder, dto.getBundle());

        builder.append(dto.getComments());
        builder.append(dto.getAnnotationData());

        // get the temp instance of the ParameterProvider so that we know the default property values
        final BundleCoordinate coordinate = getCoordinate(dto.getType(), dto.getBundle());
        final ConfigurableComponent configurableComponent = extensionManager.getTempComponent(dto.getType(), coordinate);
        if (configurableComponent == null) {
            logger.warn("Unable to get ParameterProvider of type {}; its default properties will be fingerprinted instead of being ignored.", dto.getType());
        }

        addPropertiesFingerprint(builder, configurableComponent, dto.getProperties());
    }

    private Comparator<Element> getIdsComparator() {
        return new Comparator<Element>() {
            @Override
            public int compare(final Element e1, final Element e2) {
                // compare using processor ids
                final String e1Id = getFirstValue(DomUtils.getChildNodesByTagName(e1, "id"));
                final String e2Id = getFirstValue(DomUtils.getChildNodesByTagName(e2, "id"));
                return e1Id.compareTo(e2Id);
            }
        };
    }

    private Comparator<Element> getVariableNameComparator() {
        return new Comparator<Element>() {
            @Override
            public int compare(final Element e1, final Element e2) {
                if (e1 == null && e2 == null) {
                    return 0;
                }
                if (e1 == null) {
                    return 1;
                }
                if (e2 == null) {
                    return -1;
                }

                final String varName1 = e1.getAttribute("name");
                final String varName2 = e2.getAttribute("name");
                return varName1.compareTo(varName2);
            }
        };
    }

    private Comparator<Element> getProcessorPropertiesComparator() {
        return new Comparator<Element>() {
            @Override
            public int compare(final Element e1, final Element e2) {
                // combine the property name and value for the first required property
                final String e1PropName = getFirstValue(DomUtils.getChildNodesByTagName(e1, "name"));
                String e1PropValue = getFirstValue(DomUtils.getChildNodesByTagName(e1, "value"));
                if (isEncrypted(e1PropValue)) {
                    e1PropValue = getLoggableRepresentationOfSensitiveValue(e1PropValue);
                }
                final String e1CombinedValue = e1PropName + e1PropValue;

                // combine the property name and value for the second required property
                final String e2PropName = getFirstValue(DomUtils.getChildNodesByTagName(e2, "name"));
                String e2PropValue = getFirstValue(DomUtils.getChildNodesByTagName(e2, "value"));
                if (isEncrypted(e2PropValue)) {
                    e2PropValue = getLoggableRepresentationOfSensitiveValue(e2PropValue);
                }
                final String e2CombinedValue = e2PropName + e2PropValue;

                // compare the combined values
                return e1CombinedValue.compareTo(e2CombinedValue);
            }
        };
    }

    private Comparator<Element> getElementTextComparator() {
        return new Comparator<Element>() {
            @Override
            public int compare(final Element e1, final Element e2) {
                if (e2 == null) {
                    return -1;
                } else if (e1 == null) {
                    return 1;
                }

                return e1.getTextContent().compareTo(e2.getTextContent());
            }
        };
    }

    private List<Element> sortElements(final NodeList nodeList, final Comparator<Element> comparator) {

        final List<Element> result = new ArrayList<>();

        // add node list to sorted list
        for (int i = 0; i < nodeList.getLength(); i++) {
            result.add((Element) nodeList.item(i));
        }

        Collections.sort(result, comparator);

        return result;
    }

    private String getValue(final Node node) {
        return getValue(node, NO_VALUE);
    }

    private String getValue(final Node node, final String defaultValue) {
        return StringUtils.isBlank(node.getTextContent()) ? defaultValue : node.getTextContent().trim();
    }

    private String getValue(final String value, final String defaultValue) {
        return StringUtils.isBlank(value) ? defaultValue : value;
    }

    private String getFirstValue(final NodeList nodeList) {
        return getFirstValue(nodeList, NO_VALUE);
    }

    private String getFirstValue(final NodeList nodeList, final String defaultValue) {
        return nodeList == null || nodeList.getLength() == 0 ? defaultValue : getValue(nodeList.item(0));
    }

    private StringBuilder appendFirstValue(final StringBuilder builder, final NodeList nodeList) {
        return appendFirstValue(builder, nodeList, NO_VALUE);
    }

    private StringBuilder appendFirstValue(final StringBuilder builder, final NodeList nodeList, final String defaultValue) {
        return builder.append(getFirstValue(nodeList, defaultValue));
    }

    private boolean isEncrypted(final String value) {
        return (value.startsWith(ENCRYPTED_VALUE_PREFIX) && value.endsWith(ENCRYPTED_VALUE_SUFFIX));
    }

    private String decrypt(final String value) throws FingerprintException {
        final int decryptStartIdx = ENCRYPTED_VALUE_PREFIX.length();
        final int decryptEndIdx = value.length() - ENCRYPTED_VALUE_SUFFIX.length();
        return encryptor.decrypt(value.substring(decryptStartIdx, decryptEndIdx));
    }
}
