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

import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.attribute.expression.language.Query;
import org.apache.nifi.attribute.expression.language.StandardPropertyValue;
import org.apache.nifi.attribute.expression.language.VariableImpact;
import org.apache.nifi.bundle.Bundle;
import org.apache.nifi.bundle.BundleCoordinate;
import org.apache.nifi.components.AllowableValue;
import org.apache.nifi.components.AsyncLoadedProcessor;
import org.apache.nifi.components.ClassloaderIsolationKeyProvider;
import org.apache.nifi.components.ConfigVerificationResult;
import org.apache.nifi.components.ConfigVerificationResult.Outcome;
import org.apache.nifi.components.ConfigurableComponent;
import org.apache.nifi.components.PropertyDescriptor;
import org.apache.nifi.components.ValidationContext;
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.components.resource.ResourceContext;
import org.apache.nifi.components.resource.ResourceReferenceFactory;
import org.apache.nifi.components.resource.ResourceReferences;
import org.apache.nifi.components.resource.StandardResourceContext;
import org.apache.nifi.components.resource.StandardResourceReferenceFactory;
import org.apache.nifi.components.validation.DisabledServiceValidationResult;
import org.apache.nifi.components.validation.EnablingServiceValidationResult;
import org.apache.nifi.components.validation.ValidationState;
import org.apache.nifi.components.validation.ValidationStatus;
import org.apache.nifi.components.validation.ValidationTrigger;
import org.apache.nifi.controller.service.ControllerServiceDisabledException;
import org.apache.nifi.controller.service.ControllerServiceNode;
import org.apache.nifi.controller.service.ControllerServiceProvider;
import org.apache.nifi.controller.service.ControllerServiceState;
import org.apache.nifi.flow.ExecutionEngine;
import org.apache.nifi.flowanalysis.EnforcementPolicy;
import org.apache.nifi.groups.ProcessGroup;
import org.apache.nifi.nar.ExtensionManager;
import org.apache.nifi.nar.NarCloseable;
import org.apache.nifi.parameter.ExpressionLanguageAgnosticParameterParser;
import org.apache.nifi.parameter.ExpressionLanguageAwareParameterParser;
import org.apache.nifi.parameter.Parameter;
import org.apache.nifi.parameter.ParameterContext;
import org.apache.nifi.parameter.ParameterDescriptor;
import org.apache.nifi.parameter.ParameterLookup;
import org.apache.nifi.parameter.ParameterParser;
import org.apache.nifi.parameter.ParameterReference;
import org.apache.nifi.parameter.ParameterTokenList;
import org.apache.nifi.parameter.ParameterUpdate;
import org.apache.nifi.util.CharacterFilterUtils;
import org.apache.nifi.util.FormatUtils;
import org.apache.nifi.util.file.classloader.ClassLoaderUtils;
import org.apache.nifi.validation.RuleViolation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;

public abstract class AbstractComponentNode implements ComponentNode {
    private static final String PERFORM_VALIDATION_STEP_NAME = "Perform Validation";
    private static final Logger logger = LoggerFactory.getLogger(AbstractComponentNode.class);

    private final String id;
    private final ValidationContextFactory validationContextFactory;
    private final ControllerServiceProvider serviceProvider;
    private final AtomicReference<String> name;
    private final AtomicReference<String> annotationData = new AtomicReference<>();
    private final String componentType;
    private final String componentCanonicalClass;
    private final ReloadComponent reloadComponent;
    private final ExtensionManager extensionManager;

    private final AtomicBoolean isExtensionMissing;

    private final Lock lock = new ReentrantLock();
    private final ConcurrentMap<PropertyDescriptor, PropertyConfiguration> properties = new ConcurrentHashMap<>();
    private final AtomicReference<Set<String>> sensitiveDynamicPropertyNames = new AtomicReference<>(new HashSet<>());
    private volatile String additionalResourcesFingerprint;
    private final AtomicReference<ValidationState> validationState = new AtomicReference<>(new ValidationState(ValidationStatus.VALIDATING, Collections.emptyList()));
    private final ValidationTrigger validationTrigger;
    private volatile boolean triggerValidation = true;
    private final Map<String, Integer> parameterReferenceCounts = new ConcurrentHashMap<>();

    // guarded by lock
    private ValidationContext validationContext = null;

    public AbstractComponentNode(final String id,
                                 final ValidationContextFactory validationContextFactory, final ControllerServiceProvider serviceProvider,
                                 final String componentType, final String componentCanonicalClass, final ReloadComponent reloadComponent,
                                 final ExtensionManager extensionManager, final ValidationTrigger validationTrigger, final boolean isExtensionMissing) {
        this.id = id;
        this.validationContextFactory = validationContextFactory;
        this.serviceProvider = serviceProvider;
        this.name = new AtomicReference<>(componentType);
        this.componentType = componentType;
        this.componentCanonicalClass = componentCanonicalClass;
        this.reloadComponent = reloadComponent;
        this.validationTrigger = validationTrigger;
        this.extensionManager = extensionManager;
        this.isExtensionMissing = new AtomicBoolean(isExtensionMissing);
    }

    @Override
    public String getIdentifier() {
        return id;
    }

    @Override
    public void setExtensionMissing(boolean extensionMissing) {
        this.isExtensionMissing.set(extensionMissing);
    }

    @Override
    public boolean isExtensionMissing() {
        return isExtensionMissing.get();
    }

    @Override
    public String getName() {
        return name.get();
    }

    @Override
    public void setName(final String name) {
        this.name.set(CharacterFilterUtils.filterInvalidXmlCharacters(Objects.requireNonNull(name).intern()));
    }

    @Override
    public String getAnnotationData() {
        return annotationData.get();
    }

    @Override
    public void setAnnotationData(final String data) {
        annotationData.set(CharacterFilterUtils.filterInvalidXmlCharacters(data));
        logger.debug("Resetting Validation State of {} due to setting annotation data", this);
        resetValidationState();
    }

    @Override
    public Set<URL> getAdditionalClasspathResources(final List<PropertyDescriptor> propertyDescriptors) {
        return getAdditionalClasspathResources((Collection<PropertyDescriptor>) propertyDescriptors);
    }

    protected Set<URL> getAdditionalClasspathResources(final Collection<PropertyDescriptor> propertyDescriptors) {
        return getAdditionalClasspathResources(propertyDescriptors, this::getEffectivePropertyValue);
    }

    protected Set<URL> getAdditionalClasspathResources(final Collection<PropertyDescriptor> propertyDescriptors, final Function<PropertyDescriptor, String> effectiveValueLookup) {
        final Set<URL> additionalUrls = new LinkedHashSet<>();
        final ResourceReferenceFactory resourceReferenceFactory = new StandardResourceReferenceFactory();

        for (final PropertyDescriptor descriptor : propertyDescriptors) {
            if (descriptor.isDynamicClasspathModifier()) {
                final String value = effectiveValueLookup.apply(descriptor);

                if (!StringUtils.isEmpty(value)) {
                    final ResourceContext resourceContext = new StandardResourceContext(resourceReferenceFactory, descriptor);
                    final StandardPropertyValue propertyValue = new StandardPropertyValue(resourceContext, value, null, getParameterLookup());
                    final ResourceReferences references = propertyValue.evaluateAttributeExpressions().asResources().flatten();
                    additionalUrls.addAll(references.asURLs());
                }
            }
        }

        return additionalUrls;
    }

    /**
     * Determines if the given set of properties will result in a different classpath than the currently configured set of properties
     * @param properties the properties to analyze
     * @return <code>true</code> if the given set of properties will require a different classpath (and therefore a different classloader) than the currently
     *         configured set of properties
     */
    protected boolean isClasspathDifferent(final Map<PropertyDescriptor, String> properties) {
        // If any property in the given map modifies classpath and is different than the currently configured value,
        // the given properties will require a different classpath.
        for (final Map.Entry<PropertyDescriptor, String> entry : properties.entrySet()) {
            final PropertyDescriptor descriptor = entry.getKey();
            final String value = entry.getValue();
            final String currentlyConfiguredValue = getEffectivePropertyValue(descriptor);

            if (descriptor.isDynamicClasspathModifier() && !Objects.equals(value, currentlyConfiguredValue)) {
                return true;
            }
        }

        // If any property in the currently configured properties modifies classpath and is not in the given set of properties,
        // the given properties will require a different classpath.
        for (final Map.Entry<PropertyDescriptor, PropertyConfiguration> entry : getProperties().entrySet()) {
            final PropertyDescriptor descriptor = entry.getKey();
            if (descriptor.isDynamicClasspathModifier() && !properties.containsKey(descriptor)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Overwrite current Component properties using provided values with values decrypted when necessary by the caller
     *
     * @param properties Map of Property Name to Value
     */
    protected void overwriteProperties(final Map<String, String> properties) {
        // Update properties.
        final Map<String, String> updatedProperties = new HashMap<>(properties);
        final Set<String> sensitiveDynamicPropNames = new HashSet<>();
        for (final String propertyName : updatedProperties.keySet()) {
            final PropertyDescriptor descriptor = getPropertyDescriptor(propertyName);
            if (descriptor != null && descriptor.isDynamic() && descriptor.isSensitive()) {
                sensitiveDynamicPropNames.add(propertyName);
            }
        }

        // Set a null value for any property that is not specified, so that it will be removed.
        for (final PropertyDescriptor descriptor : getProperties().keySet()) {
            updatedProperties.putIfAbsent(descriptor.getName(), null);
        }

        setProperties(updatedProperties, true, sensitiveDynamicPropNames);
    }

    /**
     * Set Properties updates internal Map of Property Descriptors and values along with current definition of Sensitive Dynamic Property Names
     *
     * @param properties Property Names and Values to be updated
     * @param allowRemovalOfRequiredProperties Allow Removal of Required Properties
     * @param updatedSensitiveDynamicPropertyNames Requested Sensitive Dynamic Property Names replaces current configuration
     */
    @Override
    public void setProperties(final Map<String, String> properties, final boolean allowRemovalOfRequiredProperties, final Set<String> updatedSensitiveDynamicPropertyNames) {
        if (properties == null) {
            return;
        }

        lock.lock();
        try {
            Objects.requireNonNull(updatedSensitiveDynamicPropertyNames, "Sensitive Dynamic Property Names required");
            sensitiveDynamicPropertyNames.getAndSet(updatedSensitiveDynamicPropertyNames);

            verifyCanUpdateProperties(properties);

            // Determine the Classloader Isolation Key, if applicable, so we can determine whether or not the key changes by setting properties.
            final String initialIsolationKey = determineClasloaderIsolationKey();

            final PropertyConfigurationMapper configurationMapper = new PropertyConfigurationMapper();
            final Map<String, PropertyConfiguration> configurationMap = configurationMapper.mapRawPropertyValuesToPropertyConfiguration(this, properties);

            try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), id)) {
                boolean classpathChanged = false;
                for (final Map.Entry<String, String> entry : properties.entrySet()) {
                    final String propertyName = entry.getKey();

                    // Set sensitive status on dynamic properties after getting canonical representation of Property Descriptor
                    final PropertyDescriptor componentDescriptor = getComponent().getPropertyDescriptor(propertyName);
                    final PropertyDescriptor descriptor = componentDescriptor.isDynamic() && updatedSensitiveDynamicPropertyNames.contains(propertyName)
                            ? new PropertyDescriptor.Builder().fromPropertyDescriptor(componentDescriptor).sensitive(true).build()
                            : componentDescriptor;

                    // determine if any of the property changes require resetting the InstanceClassLoader
                    if (descriptor.isDynamicClasspathModifier()) {
                        classpathChanged = true;
                    }

                    final PropertyConfiguration currentConfiguration = this.properties.get(descriptor);
                    if (currentConfiguration != null) {
                        for (final ParameterReference reference : currentConfiguration.getParameterReferences()) {
                            parameterReferenceCounts.merge(reference.getParameterName(), -1, (a, b) -> a == 1 ? null : a + b);
                        }
                    }

                    if (propertyName != null && entry.getValue() == null) {
                        removeProperty(propertyName, allowRemovalOfRequiredProperties);
                    } else if (propertyName != null) {
                        // Use the EL-Agnostic Parameter Parser to gather the list of referenced Parameters. We do this because we want to to keep track of which parameters
                        // are referenced, regardless of whether or not they are referenced from within an EL Expression. However, we also will need to derive a different ParameterTokenList
                        // that we can provide to the PropertyConfiguration, so that when compiling the Expression Language Expressions, we are able to keep the Parameter Reference within
                        // the Expression's text.
                        final PropertyConfiguration propertyConfiguration = configurationMap.get(propertyName);
                        final List<ParameterReference> parameterReferences = propertyConfiguration.getParameterReferences();
                        for (final ParameterReference reference : parameterReferences) {
                            // increment count in map for this parameter
                            parameterReferenceCounts.merge(reference.getParameterName(), 1, (a, b) -> a == -1 ? null : a + b);
                        }

                        setProperty(descriptor, propertyConfiguration, this.properties::get);
                    }
                }

                // Determine the updated Classloader Isolation Key, if applicable.
                final String updatedIsolationKey = determineClasloaderIsolationKey();
                final boolean classloaderIsolationKeyChanged = !Objects.equals(initialIsolationKey, updatedIsolationKey);

                // if at least one property with dynamicallyModifiesClasspath(true) was set, then reload the component with the new urls
                if (classpathChanged || classloaderIsolationKeyChanged) {
                    logger.info("Updating classpath for " + this.componentType + " with the ID " + this.getIdentifier());

                    final Set<URL> additionalUrls = getAdditionalClasspathResources(getComponent().getPropertyDescriptors());
                    try {
                        reload(additionalUrls);
                    } catch (Exception e) {
                        getLogger().error("Error reloading component with id " + id + ": " + e.getMessage(), e);
                    }
                }
            }

            if (isTriggerValidation()) {
                logger.debug("Resetting Validation State of {} due to setting properties", this);
                resetValidationState();
            } else {
                logger.debug("Properties set for {} but not resettingn validation state because validation is paused", this);
            }
        } finally {
            lock.unlock();
        }
    }

    protected String determineClasloaderIsolationKey() {
        final ConfigurableComponent component = getComponent();
        if (!(component instanceof ClassloaderIsolationKeyProvider)) {
            return null;
        }

        final ValidationContext validationContext = getValidationContextFactory().newValidationContext(getProperties(), getAnnotationData(), getProcessGroupIdentifier(), getIdentifier(),
            getParameterContext(), true);

        return getClassLoaderIsolationKey(validationContext);
    }

    public void verifyCanUpdateProperties(final Map<String, String> properties) {
        verifyModifiable();

        final ParameterParser parameterParser = new ExpressionLanguageAgnosticParameterParser();

        for (final Map.Entry<String, String> entry : properties.entrySet()) {
            final String propertyName = entry.getKey();
            final String value = entry.getValue();

            final ParameterTokenList tokenList = parameterParser.parseTokens(value);
            final List<ParameterReference> referenceList = tokenList.toReferenceList();

            final PropertyDescriptor descriptor = getPropertyDescriptor(propertyName);

            // We don't want to allow a sensitive property to reference a parameter unless the value is solely a parameter reference. I.e.,
            // #{abc} is ok but password#{abc} is not.
            // However, for "ghost" components (isExtensionMissing() == true) we need to allow this, because we consider all properties sensitive.
            // If we don't allow this, we'll fail to even create the ghost component.
            if (descriptor.isSensitive() && !isExtensionMissing()) {
                if (referenceList.size() > 1) {
                    throw new IllegalArgumentException("The property '" + descriptor.getDisplayName() + "' cannot reference more than one Parameter because it is a sensitive property.");
                }

                if (referenceList.size() == 1) {
                    final ParameterReference reference = referenceList.get(0);
                    if (reference.getStartOffset() != 0 || reference.getEndOffset() != value.length() - 1) {
                        throw new IllegalArgumentException("The property '" + descriptor.getDisplayName() + "' is a sensitive property so it can reference a Parameter only if there is no other " +
                            "context around the value. For instance, the value '#{abc}' is allowed but 'password#{abc}' is not allowed.");
                    }
                }
            }
        }
    }

    protected List<ConfigVerificationResult> verifyConfig(final Map<PropertyDescriptor, String> propertyValues, final String annotationData, final ParameterContext parameterContext) {
        final List<ConfigVerificationResult> results = new ArrayList<>();

        try {
            final long startNanos = System.nanoTime();

            final Map<PropertyDescriptor, PropertyConfiguration> descriptorToConfigMap = createDescriptorToConfigMap(propertyValues);
            final ValidationContext validationContext = getValidationContextFactory().newValidationContext(descriptorToConfigMap, annotationData,
                getProcessGroupIdentifier(), getIdentifier(), parameterContext, false);

            final ValidationState validationState = performValidation(validationContext);
            final ValidationStatus validationStatus = validationState.getStatus();

            if (validationStatus == ValidationStatus.INVALID) {
                for (final ValidationResult result : validationState.getValidationErrors()) {
                    if (result.isValid()) {
                        continue;
                    }

                    results.add(new ConfigVerificationResult.Builder()
                        .verificationStepName(PERFORM_VALIDATION_STEP_NAME)
                        .outcome(Outcome.FAILED)
                        .explanation("Component is invalid: " + result)
                        .build());
                }

                if (results.isEmpty()) {
                    results.add(new ConfigVerificationResult.Builder()
                        .verificationStepName(PERFORM_VALIDATION_STEP_NAME)
                        .outcome(Outcome.FAILED)
                        .explanation("Component is invalid but provided no Validation Results to indicate why")
                        .build());
                }

                logger.debug("{} is not valid with the given configuration. Will not attempt to perform any additional verification of configuration. Validation took {}. Reason not valid: {}",
                    this, results, FormatUtils.formatNanos(System.nanoTime() - startNanos, false));
                return results;
            }

            results.add(new ConfigVerificationResult.Builder()
                .verificationStepName(PERFORM_VALIDATION_STEP_NAME)
                .outcome(Outcome.SUCCESSFUL)
                .explanation("Component Validation passed")
                .build());
        } catch (final Throwable t) {
            logger.error("Failed to perform verification of component's configuration for {}", this, t);

            results.add(new ConfigVerificationResult.Builder()
                .verificationStepName(PERFORM_VALIDATION_STEP_NAME)
                .outcome(Outcome.FAILED)
                .explanation("Encountered unexpected failure when attempting to perform verification: " + t)
                .build());
        }

        return results;
    }

    private static Map<PropertyDescriptor, PropertyConfiguration> createDescriptorToConfigMap(final Map<PropertyDescriptor, String> propertyValues) {
        final Map<PropertyDescriptor, PropertyConfiguration> descriptorToConfigMap = new LinkedHashMap<>();
        for (final Map.Entry<PropertyDescriptor, String> entry : propertyValues.entrySet()) {
            final PropertyDescriptor descriptor = entry.getKey();
            final String rawValue = entry.getValue();
            final String propertyValue = rawValue == null ? descriptor.getDefaultValue() : rawValue;

            final PropertyConfiguration propertyConfiguration = new PropertyConfiguration(propertyValue, null, Collections.emptyList(), VariableImpact.NEVER_IMPACTED);
            descriptorToConfigMap.put(descriptor, propertyConfiguration);
        }
        return descriptorToConfigMap;
    }

    @Override
    public Set<String> getReferencedParameterNames() {
        return Collections.unmodifiableSet(parameterReferenceCounts.keySet());
    }

    @Override
    public boolean isReferencingParameter() {
        return !parameterReferenceCounts.isEmpty();
    }

    @Override
    public Set<String> getReferencedAttributeNames() {
        final Set<String> referencedAttributes = new HashSet<>();

        for (final PropertyDescriptor descriptor : getPropertyDescriptors()) {
            final String effectiveValue = getEffectivePropertyValue(descriptor);
            final Set<String> attributes = Query.prepareWithParametersPreEvaluated(effectiveValue).getExplicitlyReferencedAttributes();
            referencedAttributes.addAll(attributes);
        }

        return referencedAttributes;
    }

    private String resolveAllowableValue(final String explicitValue, final PropertyDescriptor descriptor) {
        if (explicitValue == null || descriptor == null) {
            return null;
        }

        final List<AllowableValue> allowableValues = descriptor.getAllowableValues();
        if (allowableValues == null || allowableValues.isEmpty()) {
            return explicitValue;
        }

        // Check for an exact match
        for (final AllowableValue allowableValue : allowableValues) {
            if (Objects.equals(allowableValue.getValue(), explicitValue)) {
                return explicitValue;
            }
        }

        // Check for a value that is equal ignoring case
        for (final AllowableValue allowableValue : allowableValues) {
            if (allowableValue.getValue().equalsIgnoreCase(explicitValue)) {
                return allowableValue.getValue();
            }
        }

        // Check for an exact match against the display name
        for (final AllowableValue allowableValue : allowableValues) {
            final String displayName = allowableValue.getDisplayName();
            if (displayName == null) {
                continue;
            }

            if (Objects.equals(displayName, explicitValue)) {
                return allowableValue.getValue();
            }
        }

        // Check for a match against display name, ignoring case
        for (final AllowableValue allowableValue : allowableValues) {
            final String displayName = allowableValue.getDisplayName();
            if (displayName == null) {
                continue;
            }

            if (displayName.equalsIgnoreCase(explicitValue)) {
                return allowableValue.getValue();
            }
        }

        // No match found - just return the explicit value
        return explicitValue;
    }

    // Keep setProperty/removeProperty private so that all calls go through setProperties
    private void setProperty(final PropertyDescriptor descriptor, final PropertyConfiguration propertyConfiguration, final Function<PropertyDescriptor, PropertyConfiguration> valueToCompareFunction) {
        // Remove current PropertyDescriptor to force updated instance references
        final PropertyConfiguration removed = properties.remove(descriptor);

        final PropertyConfiguration propertyModComparisonValue = valueToCompareFunction.apply(descriptor);
        properties.put(descriptor, propertyConfiguration);
        final String effectiveValue = propertyConfiguration.getEffectiveValue(getParameterContext());
        final String resolvedValue = resolveAllowableValue(effectiveValue, descriptor);

        // If the property references a Controller Service, we need to register this component & property descriptor as a reference.
        // If it previously referenced a Controller Service, we need to also remove that reference.
        // It is okay if the new & old values are the same - we just unregister the component/descriptor and re-register it.
        if (descriptor.getControllerServiceDefinition() != null) {
            Optional.ofNullable(removed)
                .map(_oldConfiguration -> _oldConfiguration.getEffectiveValue(getParameterContext()))
                .map(oldEffectiveValue -> serviceProvider.getControllerServiceNode(oldEffectiveValue))
                .ifPresent(oldNode -> oldNode.removeReference(this, descriptor));

            Optional.ofNullable(resolvedValue)
                .map(serviceProvider::getControllerServiceNode)
                .ifPresent(newNode -> newNode.addReference(this, descriptor));
        }

        // In the case of a component "reload", we want to call onPropertyModified when the value is changed from the descriptor's default.
        // However, we do not want to update any controller service references because those are tied to the ComponentNode. We only want to
        // allow the newly created component's internal state to be updated.
        if (!propertyConfiguration.equals(propertyModComparisonValue)) {
            try {
                final String oldValue = propertyModComparisonValue == null ? null : propertyModComparisonValue.getEffectiveValue(getParameterContext());
                onPropertyModified(descriptor, oldValue, resolvedValue);
            } catch (final Exception e) {
                // nothing really to do here...
                logger.error("Failed to notify {} that property {} changed", this, descriptor, e);
            }
        }
    }

    /**
     * Removes the property and value for the given property name if a
     * descriptor and value exists for the given name. If the property is
     * optional its value might be reset to default or will be removed entirely
     * if was a dynamic property.
     *
     * @param name the property to remove
     * @param allowRemovalOfRequiredProperties whether or not the property should be removed if it's required
     * @return true if removed; false otherwise
     * @throws java.lang.IllegalArgumentException if the name is null
     */
    private boolean removeProperty(final String name, final boolean allowRemovalOfRequiredProperties) {
        if (null == name) {
            throw new IllegalArgumentException("Name can not be null");
        }

        final PropertyDescriptor descriptor = getComponent().getPropertyDescriptor(name);

        final boolean allowRemoval = allowRemovalOfRequiredProperties || !descriptor.isRequired();
        if (!allowRemoval) {
            return false;
        }

        final PropertyConfiguration propertyConfiguration = properties.remove(descriptor);
        if (propertyConfiguration == null || propertyConfiguration.getRawValue() == null) {
            return false;
        }

        final String value = propertyConfiguration.getEffectiveValue(getParameterContext());
        if (descriptor.getControllerServiceDefinition() != null) {
            if (value != null) {
                final ControllerServiceNode oldNode = serviceProvider.getControllerServiceNode(value);
                if (oldNode != null) {
                    oldNode.removeReference(this, descriptor);
                }
            }
        }

        try {
            onPropertyModified(descriptor, value, null);
        } catch (final Exception e) {
            getLogger().error(e.getMessage(), e);
        }

        return true;
    }

    public Map<PropertyDescriptor, PropertyConfiguration> getProperties() {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getIdentifier())) {
            final List<PropertyDescriptor> supported = getComponent().getPropertyDescriptors();
            if (supported == null || supported.isEmpty()) {
                return Collections.unmodifiableMap(properties);
            } else {
                final Map<PropertyDescriptor, PropertyConfiguration> props = new LinkedHashMap<>();

                for (final PropertyDescriptor descriptor : supported) {
                    // Get Canonical Property Descriptor
                    props.put(getPropertyDescriptor(descriptor.getName()), null);
                }

                // Get Canonical Property Descriptor for returned Map of properties
                properties.forEach((descriptor, config) -> props.put(getPropertyDescriptor(descriptor.getName()), config));
                return props;
            }
        }
    }

    @Override
    public Map<PropertyDescriptor, String> getRawPropertyValues() {
        return getPropertyValues((descriptor, config) -> config.getRawValue());
    }

    @Override
    public Map<PropertyDescriptor, String> getEffectivePropertyValues() {
        return getPropertyValues((descriptor, config) -> getConfigValue(config, isResolveParameter(descriptor, config)));
    }

    /**
     * Converts from a Map of PropertyDescriptor to value, to a Map of property name to value
     *
     * @param propertyValues the property values to convert
     * @return a Map whose keys are the names of the properties instead of descriptors
     */
    public Map<String, String> toPropertyNameMap(final Map<PropertyDescriptor, String> propertyValues) {
        final Map<String, String> converted = new HashMap<>();
        propertyValues.forEach((key, value) -> converted.put(key.getName(), value));
        return converted;
    }

    private Map<PropertyDescriptor, String> getPropertyValues(final BiFunction<PropertyDescriptor, PropertyConfiguration, String> valueFunction) {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getIdentifier())) {
            final List<PropertyDescriptor> supported = getComponent().getPropertyDescriptors();

            final Map<PropertyDescriptor, String> props = new LinkedHashMap<>();
            for (final PropertyDescriptor descriptor : supported) {
                if (descriptor != null) {
                    final PropertyDescriptor upToDateDescriptor = getPropertyDescriptor(descriptor.getName());
                    props.put(upToDateDescriptor, upToDateDescriptor.getDefaultValue());
                }
            }

            properties.forEach((descriptor, config) -> {
                final PropertyDescriptor upToDateDescriptor = getPropertyDescriptor(descriptor.getName());
                props.put(upToDateDescriptor, valueFunction.apply(upToDateDescriptor, config));
            });
            return props;
        }
    }

    @Override
    public PropertyConfiguration getProperty(final PropertyDescriptor property) {
        final PropertyConfiguration configuration = properties.get(property);
        return (configuration == null) ? PropertyConfiguration.EMPTY : configuration;
    }

    @Override
    public String getEffectivePropertyValue(final PropertyDescriptor property) {
        return getProperty(property).getEffectiveValue(getParameterContext());
    }

    private String getEffectivePropertyValueWithDefault(final PropertyDescriptor property) {
        String value = getProperty(property).getEffectiveValue(getParameterContext());
        if (value == null) {
            value = property.getDefaultValue();
        }
        return value;
    }

    protected String mapRawValueToEffectiveValue(final String rawValue) {
        if (rawValue == null) {
            return null;
        }

        final ParameterLookup parameterLookup = getParameterLookup();
        if (parameterLookup == null) {
            return rawValue;
        }

        final ParameterTokenList parameterTokenList = new ExpressionLanguageAgnosticParameterParser().parseTokens(rawValue);
        final String effectiveValue = parameterTokenList.substitute(parameterLookup);
        return effectiveValue;
    }

    @Override
    public String getRawPropertyValue(final PropertyDescriptor property) {
        return getProperty(property).getRawValue();
    }

    @Override
    public void refreshProperties() {
        // use setProperty instead of setProperties so we can bypass the class loading logic.
        // Consider value changed if it is different than the PropertyDescriptor's default value because we need to call the #onPropertiesModified
        // method on the component if the current value is not the default value, since the component itself is being reloaded.
        // Also, create a copy of this.properties instead of iterating directly over this.properties since the call to setProperty can change the
        // underlying map, and the behavior of modifying the map while iterating over its elements is undefined.
        final Map<PropertyDescriptor, PropertyConfiguration> copyOfPropertiesMap = new HashMap<>(this.properties);
        for (final Map.Entry<PropertyDescriptor, PropertyConfiguration> entry : copyOfPropertiesMap.entrySet()) {
            final PropertyDescriptor propertyDescriptor = entry.getKey();
            final PropertyConfiguration configuration = entry.getValue();

            if (propertyDescriptor == null || configuration == null || configuration.getRawValue() == null) {
                continue;
            }

            setProperty(propertyDescriptor, configuration, descriptor -> createPropertyConfiguration(descriptor.getDefaultValue()));
        }
    }

    private PropertyConfiguration createPropertyConfiguration(final String value) {
        final ParameterParser parser = new ExpressionLanguageAwareParameterParser();
        final ParameterTokenList references = parser.parseTokens(value);
        final VariableImpact variableImpact = Query.prepare(value).getVariableImpact();
        return new PropertyConfiguration(value, references, references.toReferenceList(), variableImpact);
    }

    /**
     * Generates fingerprint for the additional urls and compares it with the previous
     * fingerprint value.
     */
    @Override
    public synchronized boolean isReloadAdditionalResourcesNecessary() {
        // Components that don't have any PropertyDescriptors marked `dynamicallyModifiesClasspath`
        // won't have the fingerprint i.e. will be null, in such cases do nothing
        if (additionalResourcesFingerprint == null) {
            return false;
        }

        final Set<PropertyDescriptor> descriptors = this.getProperties().keySet();
        final Set<URL> additionalUrls = this.getAdditionalClasspathResources(descriptors);

        final String newFingerprint = ClassLoaderUtils.generateAdditionalUrlsFingerprint(additionalUrls, determineClasloaderIsolationKey());
        return (!StringUtils.equals(additionalResourcesFingerprint, newFingerprint));
    }

    /**
     * Generates fingerprint for the additional urls and compares it with the previous
     * fingerprint value. If the fingerprint values don't match, the function calls the
     * component's reload() to load the newly found resources.
     */
    @Override
    public synchronized void reloadAdditionalResourcesIfNecessary() {
        final Set<PropertyDescriptor> descriptors = this.getProperties().keySet();

        final boolean dynamicallyModifiesClasspath = descriptors.stream()
                .anyMatch(PropertyDescriptor::isDynamicClasspathModifier);

        if (dynamicallyModifiesClasspath) {
            final Set<URL> additionalUrls = this.getAdditionalClasspathResources(descriptors, this::getEffectivePropertyValueWithDefault);

            final String newFingerprint = ClassLoaderUtils.generateAdditionalUrlsFingerprint(additionalUrls, determineClasloaderIsolationKey());
            if (!StringUtils.equals(additionalResourcesFingerprint, newFingerprint)) {
                setAdditionalResourcesFingerprint(newFingerprint);
                try {
                    logger.info("Updating classpath for [{}] with the ID [{}]", this.componentType, this.getIdentifier());
                    reload(additionalUrls);
                } catch (Exception e) {
                    logger.error("Error reloading component with id [{}]: {}", id, e.getMessage(), e);
                }
            }
        }
    }

    @Override
    public int hashCode() {
        return 273171 * id.hashCode();
    }

    @Override
    public boolean equals(final Object obj) {
        if (obj == this) {
            return true;
        }
        if (obj == null) {
            return false;
        }

        if (!(obj instanceof ComponentNode)) {
            return false;
        }

        final ComponentNode other = (ComponentNode) obj;
        return id.equals(other.getIdentifier());
    }

    @Override
    public String toString() {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getComponent().getIdentifier())) {
            return getComponent().toString();
        } catch (final Throwable t) {
            return getClass().getSimpleName() + "[id=" + getIdentifier() + "]";
        }
    }

    @Override
    public ValidationState performValidation(final Map<PropertyDescriptor, PropertyConfiguration> properties, final String annotationData, final ParameterContext parameterContext) {
        final ValidationContext validationContext = validationContextFactory.newValidationContext(properties, annotationData, getProcessGroupIdentifier(), getIdentifier(), parameterContext, true);
        return performValidation(validationContext);
    }

    @Override
    public ValidationState performValidation(final ValidationContext validationContext) {
        final Collection<ValidationResult> results;
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getIdentifier())) {
            results = computeValidationErrors(validationContext);
        }

        final ValidationStatus status = results.isEmpty() ? ValidationStatus.VALID : ValidationStatus.INVALID;
        final ValidationState validationState = new ValidationState(status, results);
        return validationState;
    }

    @Override
    public final ValidationStatus performValidation() {
        while (true) {
            final ValidationState validationState = getValidationState();

            final ValidationContext validationContext = getValidationContext();
            final ValidationState updatedState = performValidation(validationContext);
            final boolean replaced = replaceValidationState(validationState, updatedState);
            if (replaced) {
                return updatedState.getStatus();
            }
        }
    }

    protected Collection<ValidationResult> computeValidationErrors(final ValidationContext validationContext) {
        Throwable failureCause = null;

        try {
            if (!sensitiveDynamicPropertyNames.get().isEmpty() && !isSupportsSensitiveDynamicProperties()) {
                return Collections.singletonList(
                        new ValidationResult.Builder()
                                .subject("Component")
                                .valid(false)
                                .explanation(String.format("Sensitive Dynamic Properties %s configured but not supported", sensitiveDynamicPropertyNames))
                                .build()
                );
            }

            final ConfigurableComponent component = getComponent();
            if (component instanceof final AsyncLoadedProcessor asyncLoadedProcessor) {
                if (!asyncLoadedProcessor.isLoaded()) {
                    final String explanation = switch (asyncLoadedProcessor.getState()) {
                        case INITIALIZING_ENVIRONMENT -> "Initializing runtime environment for the Processor.";
                        case DEPENDENCY_DOWNLOAD_FAILED -> "Failed to download one or more Processor dependencies. See logs for additional details.";
                        case DOWNLOADING_DEPENDENCIES -> "In the process of downloading third-party dependencies required by the Processor.";
                        case LOADING_PROCESSOR_CODE -> "In the process of loading Processor code";
                        case LOADING_PROCESSOR_CODE_FAILED -> "Failed to parse or load Processor code. See logs for additional details.";
                        default -> null;
                    };

                    return Collections.singletonList(new ValidationResult.Builder()
                        .subject("Processor")
                        .explanation(explanation)
                        .valid(false)
                        .build());
                }
            }

            final List<ValidationResult> invalidParameterResults = validateParameterReferences(validationContext);
            invalidParameterResults.addAll(validateConfig());

            if (!invalidParameterResults.isEmpty()) {
                // At this point, we are not able to properly resolve all property values, so we will not attempt to perform
                // any further validation. Doing so would result in values being reported as invalid and containing confusing explanations.
                return invalidParameterResults;
            }

            final Collection<ValidationResult> results = getComponent().validate(validationContext);
            final List<ValidationResult> validationResults = new ArrayList<>(results);

            // validate selected controller services implement the API required by the processor
            final Collection<ValidationResult> referencedServiceValidationResults = validateReferencedControllerServices(validationContext);
            validationResults.addAll(referencedServiceValidationResults);

            performFlowAnalysisOnThis();

            getValidationContextFactory().getRuleViolationsManager().ifPresent(ruleViolationsManager -> {
                Collection<RuleViolation> ruleViolations = ruleViolationsManager.getRuleViolationsForSubject(getIdentifier());
                for (RuleViolation ruleViolation : ruleViolations) {
                    if (ruleViolation.getEnforcementPolicy() == EnforcementPolicy.ENFORCE) {
                        validationResults.add(
                            new ValidationResult.Builder()
                                .subject(getComponent().getClass().getSimpleName())
                                .valid(false)
                                .explanation(ruleViolation.getViolationMessage())
                                .build()
                        );
                    }
                }
            });

            logger.debug("Computed validation errors with Validation Context {}; results = {}", validationContext, validationResults);

            return validationResults;
        } catch (final ControllerServiceDisabledException e) {
            getLogger().debug("Failed to perform validation due to " + e, e);
            return Collections.singleton(
                new DisabledServiceValidationResult("Component", e.getControllerServiceId(), "performing validation depends on referencing a Controller Service that is currently disabled"));
        } catch (final Exception e) {
            // We don't want to log this as an error because we will return a ValidationResult that is
            // invalid. However, we do want to make the stack trace available if needed, so we log it at
            // a debug level.
            getLogger().debug("Failed to perform validation due to " + e, e);
            failureCause = e;
        } catch (final Error e) {
            getLogger().error("Failed to perform validation due to " + e, e);
            failureCause = e;
        }

        return Collections.singleton(new ValidationResult.Builder()
            .subject("Component")
            .valid(false)
            .explanation("Failed to perform validation due to " + failureCause)
            .build());
    }

    /**
     * Validates the current configuration, returning ValidationResults for any
     * invalid configuration parameter.
     *
     * @return Collection of validation result objects for any invalid findings
     *         only. If the collection is empty then the component is valid. Should guarantee
     *         non-null
     */
    protected abstract List<ValidationResult> validateConfig();

    private List<ValidationResult> validateParameterReferences(final ValidationContext validationContext) {
        final List<ValidationResult> results = new ArrayList<>();

        final ParameterContext parameterContext = getParameterContext();
        final boolean assignedToProcessGroup = getProcessGroupIdentifier() != null;

        final ConfigurableComponent component = getComponent();
        for (final PropertyDescriptor propertyDescriptor : validationContext.getProperties().keySet()) {
            // If the property descriptor's dependency is not satisfied, the property does not need to be considered, as it's not relevant to the
            // component's functionality.
            final boolean dependencySatisfied = validationContext.isDependencySatisfied(propertyDescriptor, component::getPropertyDescriptor);
            if (!dependencySatisfied) {
                continue;
            }

            final Collection<String> referencedParameters = validationContext.getReferencedParameters(propertyDescriptor.getName());

            if (parameterContext == null && !referencedParameters.isEmpty()) {
                results.add(new ValidationResult.Builder()
                    .subject(propertyDescriptor.getDisplayName())
                    .valid(false)
                    .explanation(assignedToProcessGroup ? "Property references one or more Parameters but no Parameter Context is currently set on the Process Group"
                        : "Property references one or more Parameters, but Parameters may be referenced only by Processors and Controller Services that reside within a Process Group.")
                    .build());

                continue;
            }

            for (final String paramName : referencedParameters) {
                if (!validationContext.isParameterDefined(paramName)) {
                    results.add(new ValidationResult.Builder()
                            .subject(propertyDescriptor.getDisplayName())
                            .valid(false)
                            .explanation("Property references Parameter '" + paramName + "' but the currently selected Parameter Context does not have a Parameter with that name")
                            .build());
                    continue;
                }

                final Optional<Parameter> parameterRef = parameterContext.getParameter(paramName);
                if (parameterRef.isPresent()) {
                    final ParameterDescriptor parameterDescriptor = parameterRef.get().getDescriptor();
                    if (parameterDescriptor.isSensitive() != propertyDescriptor.isSensitive()) {
                        results.add(new ValidationResult.Builder()
                                .subject(propertyDescriptor.getDisplayName())
                                .valid(false)
                                .explanation("The property '" + propertyDescriptor.getDisplayName() + "' cannot reference Parameter '" + parameterDescriptor.getName()
                                        + "' because the Sensitivity of the parameter does not match the Sensitivity of the property.")
                                .build());
                    }
                }
            }
        }

        return results;
    }

    protected final Collection<ValidationResult> validateReferencedControllerServices(final ValidationContext validationContext) {
        final Set<PropertyDescriptor> propertyDescriptors = validationContext.getProperties().keySet();

        final ConfigurableComponent component = getComponent();
        final Collection<ValidationResult> validationResults = new ArrayList<>();
        for (final PropertyDescriptor descriptor : propertyDescriptors) {
            if (descriptor.getControllerServiceDefinition() == null) {
                // skip properties that aren't for a controller service
                continue;
            }

            final boolean dependencySatisfied = validationContext.isDependencySatisfied(descriptor, component::getPropertyDescriptor);
            if (!dependencySatisfied) {
                continue;
            }

            final String controllerServiceId = validationContext.getProperty(descriptor).getValue();
            if (controllerServiceId == null) {
                continue;
            }

            final ControllerServiceNode controllerServiceNode = getControllerServiceProvider().getControllerServiceNode(controllerServiceId);
            if (controllerServiceNode == null) {
                final ValidationResult result = createInvalidResult(controllerServiceId, descriptor.getDisplayName(),
                    "Invalid Controller Service: " + controllerServiceId + " is not a valid Controller Service Identifier");

                validationResults.add(result);
                continue;
            }

            final ValidationResult apiResult = validateControllerServiceApi(descriptor, controllerServiceNode);
            if (apiResult != null) {
                validationResults.add(apiResult);
                continue;
            }

            if (!controllerServiceNode.isActive()) {
                // If the component belongs to a Process Group and the Process Group is configured to use the Stateless Engine,
                // do not validate that the Controller Service is valid. We do this because we cannot start & stop Controller Services
                // individually. But if using the standard NiFi engine, or if the component doesn't belong to a Process Group,
                // we want to require that the referenced Controller Service is invalid.
                // Additionally, if the Controller Service is in a higher level Process Group that is not using the Stateless Engine, it must be enabled.
                final ProcessGroup processGroup = getParentProcessGroup().orElse(null);
                final ProcessGroup csGroup = controllerServiceNode.getProcessGroup();
                final boolean csGroupStateless = csGroup != null && csGroup.resolveExecutionEngine() == ExecutionEngine.STATELESS;
                if (processGroup == null || processGroup.resolveExecutionEngine() == ExecutionEngine.STANDARD || !csGroupStateless) {
                    validationResults.add(new DisabledServiceValidationResult(descriptor.getDisplayName(), controllerServiceId));
                }
            } else if (ControllerServiceState.ENABLING == controllerServiceNode.getState()) {
                validationResults.add(new EnablingServiceValidationResult(descriptor.getDisplayName(), controllerServiceId));
            }
        }

        return validationResults;
    }


    private ValidationResult validateControllerServiceApi(final PropertyDescriptor descriptor, final ControllerServiceNode controllerServiceNode) {
        final Class<? extends ControllerService> controllerServiceApiClass = descriptor.getControllerServiceDefinition();
        // If a processor accepts any service don't validate it.
        if (controllerServiceApiClass.equals(ControllerService.class)) {
            return null;
        }

        final ClassLoader controllerServiceApiClassLoader = controllerServiceApiClass.getClassLoader();
        final ExtensionManager extensionManager = serviceProvider.getExtensionManager();

        final String serviceId = controllerServiceNode.getIdentifier();
        final String propertyName = descriptor.getDisplayName();

        final Bundle controllerServiceApiBundle = extensionManager.getBundle(controllerServiceApiClassLoader);
        if (controllerServiceApiBundle == null) {
            return createInvalidResult(serviceId, propertyName, "Unable to find bundle for ControllerService API class " + controllerServiceApiClass.getCanonicalName());
        }
        final BundleCoordinate controllerServiceApiCoordinate = controllerServiceApiBundle.getBundleDetails().getCoordinate();

        Bundle controllerServiceBundle = extensionManager.getBundle(controllerServiceNode.getBundleCoordinate());
        final boolean matchesApiByBundleCoordinates;
        if (controllerServiceBundle == null) {
            final List<Bundle> possibleBundles = extensionManager.getBundles(controllerServiceNode.getControllerServiceImplementation().getClass().getName());
            if (possibleBundles.size() != 1) {
                return createInvalidResult(serviceId, propertyName, "Unable to find bundle for coordinate " + controllerServiceNode.getBundleCoordinate());
            }

            controllerServiceBundle = possibleBundles.get(0);
            matchesApiByBundleCoordinates = false;
        } else {
            matchesApiByBundleCoordinates = matchesApiBundleCoordinates(extensionManager, controllerServiceBundle, controllerServiceApiCoordinate);
        }

        final BundleCoordinate controllerServiceCoordinate = controllerServiceBundle.getBundleDetails().getCoordinate();
        if (!matchesApiByBundleCoordinates) {
            final Class<? extends ControllerService> controllerServiceImplClass = controllerServiceNode.getControllerServiceImplementation().getClass();
            logger.debug("Comparing methods from service api '{}' against service implementation '{}'",
                    new Object[]{controllerServiceApiClass.getCanonicalName(), controllerServiceImplClass.getCanonicalName()});

            final ControllerServiceApiMatcher controllerServiceApiMatcher = new ControllerServiceApiMatcher();
            final boolean matchesApi = controllerServiceApiMatcher.matches(controllerServiceApiClass, controllerServiceImplClass);

            if (!matchesApi) {
                final String controllerServiceType = controllerServiceNode.getComponentType();
                final String controllerServiceApiType = controllerServiceApiClass.getSimpleName();

                final String explanation = new StringBuilder()
                        .append(controllerServiceType).append(" - ").append(controllerServiceCoordinate.getVersion())
                        .append(" from ").append(controllerServiceCoordinate.getGroup()).append(" - ").append(controllerServiceCoordinate.getId())
                        .append(" is not compatible with ").append(controllerServiceApiType).append(" - ").append(controllerServiceApiCoordinate.getVersion())
                        .append(" from ").append(controllerServiceApiCoordinate.getGroup()).append(" - ").append(controllerServiceApiCoordinate.getId())
                        .toString();

                return createInvalidResult(serviceId, propertyName, explanation);
            }
        }

        return null;
    }

    protected void performFlowAnalysisOnThis() {
    }

    private ValidationResult createInvalidResult(final String serviceId, final String propertyName, final String explanation) {
        return new ValidationResult.Builder()
            .input(serviceId)
            .subject(propertyName)
            .valid(false)
            .explanation(explanation)
            .build();
    }

    /**
     * Determines if the given controller service node has the required API as an ancestor.
     *
     * @param controllerServiceImplBundle the bundle of a controller service being referenced by a processor
     * @param requiredApiCoordinate the controller service API required by the processor
     * @return true if the controller service node has the require API as an ancestor, false otherwise
     */
    private boolean matchesApiBundleCoordinates(final ExtensionManager extensionManager, final Bundle controllerServiceImplBundle, final BundleCoordinate requiredApiCoordinate) {
        // start with the coordinate of the controller service for cases where the API and service are in the same bundle
        BundleCoordinate controllerServiceDependencyCoordinate = controllerServiceImplBundle.getBundleDetails().getCoordinate();

        boolean foundApiDependency = false;
        while (controllerServiceDependencyCoordinate != null) {
            // determine if the dependency coordinate matches the required API
            if (requiredApiCoordinate.equals(controllerServiceDependencyCoordinate)) {
                foundApiDependency = true;
                break;
            }

            // move to the next dependency in the chain, or stop if null
            final Bundle controllerServiceDependencyBundle = extensionManager.getBundle(controllerServiceDependencyCoordinate);
            if (controllerServiceDependencyBundle == null) {
                controllerServiceDependencyCoordinate = null;
            } else {
                controllerServiceDependencyCoordinate = controllerServiceDependencyBundle.getBundleDetails().getDependencyCoordinate();
            }
        }

        return foundApiDependency;
    }

    @Override
    public boolean isSensitiveDynamicProperty(final String name) {
        Objects.requireNonNull(name, "Property Name required");
        return sensitiveDynamicPropertyNames.get().contains(name);
    }

    @Override
    public PropertyDescriptor getPropertyDescriptor(final String name) {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getComponent().getIdentifier())) {
            final PropertyDescriptor propertyDescriptor = getComponent().getPropertyDescriptor(name);
            if (propertyDescriptor.isDynamic() && isSensitiveDynamicProperty(name)) {
                return new PropertyDescriptor.Builder().fromPropertyDescriptor(propertyDescriptor).sensitive(true).build();
            } else {
                return propertyDescriptor;
            }
        }
    }

    @Override
    public List<PropertyDescriptor> getPropertyDescriptors() {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getComponent().getIdentifier())) {
            return getComponent().getPropertyDescriptors();
        }
    }


    protected void onPropertyModified(final PropertyDescriptor descriptor, final String oldValue, final String newValue) {
        try (final NarCloseable narCloseable = NarCloseable.withComponentNarLoader(extensionManager, getComponent().getClass(), getComponent().getIdentifier())) {
            getComponent().onPropertyModified(descriptor, oldValue, newValue);
        }
    }

    @Override
    public void onParametersModified(final Map<String, ParameterUpdate> updatedParameters) {
        // If the component doesn't reference any parameters, then there's nothing to be done.
        if (!isReferencingParameter()) {
            return;
        }

        final ParameterLookup previousParameterLookup = createParameterLookupForPreviousValues(updatedParameters);

        // For any Property that references an updated Parameter, we need to call onPropertyModified().
        // Additionally, we need to trigger validation to run if this component is affected by the parameter update.
        boolean componentAffected = false;

        //Determine if the component is affected by the Parameter Update
        for (final String updatedParameterName : updatedParameters.keySet()) {
            if (isReferencingParameter(updatedParameterName)) {
                componentAffected = true;
                break;
            }
        }

        for (final Map.Entry<PropertyDescriptor, PropertyConfiguration> entry : properties.entrySet()) {
            final PropertyDescriptor propertyDescriptor = entry.getKey();
            final PropertyConfiguration configuration = entry.getValue();

            // Determine if this property is affected by the Parameter Update
            boolean propertyAffected = false;
            final List<ParameterReference> parameterReferences = configuration.getParameterReferences();
            for (final ParameterReference reference : parameterReferences) {
                final String referencedParamName = reference.getParameterName();
                if (updatedParameters.containsKey(referencedParamName)) {
                    propertyAffected = true;
                    break;
                }
            }

            if (propertyAffected) {
                final String previousValue = configuration.getEffectiveValue(previousParameterLookup);
                final String updatedValue = configuration.getEffectiveValue(getParameterContext());

                // Check if the value of the property is truly affected. It's possible that we could have a property configured as something like "#{a}#{b}"
                // Where parameter a = "abc-" and b = "cba". The update could change a to "abc" and b to "-cba". As a result, the property value previously was "abc-cba" and still is.
                // In such a case, we should not call onPropertyModified.
                final boolean propertyUpdated = !Objects.equals(previousValue, updatedValue);
                if (propertyUpdated) {
                    try {
                        logger.debug("Parameter Context updated, resulting in property {} of {} changing. Calling onPropertyModified().", propertyDescriptor, this);
                        onPropertyModified(propertyDescriptor, previousValue, updatedValue);
                    } catch (final Exception e) {
                        // nothing really to do here...
                        logger.error("Failed to notify {} that property {} changed", this, propertyDescriptor, e);
                    }
                } else {
                    logger.debug("Parameter Context updated, and property {} of {} does reference the updated Parameters. However, the overall property value remained unchanged so will not call " +
                        "onPropertyModified().", propertyDescriptor, this);
                }
            }
        }

        // If this component is affected by the Parameter change, we need to re-validate
        if (componentAffected) {
            logger.debug("Configuration of {} changed due to an update to Parameter Context. Resetting validation state", this);
            resetValidationState();
        }
    }

    protected void incrementReferenceCounts(final String parameterName) {
        parameterReferenceCounts.merge(parameterName, 1, (a, b) -> a == -1 ? null : a + b);
    }

    protected void decrementReferenceCounts(final String parameterName) {
        parameterReferenceCounts.merge(parameterName, -1, (a, b) -> a == 1 ? null : a + b);
    }

    private ParameterLookup createParameterLookupForPreviousValues(final Map<String, ParameterUpdate> updatedParameters) {
        final ParameterContext currentContext = getParameterContext();
        return new ParameterLookup() {
            @Override
            public Optional<Parameter> getParameter(final String parameterName) {
                final Optional<Parameter> optionalParameter = currentContext == null ? Optional.empty() : currentContext.getParameter(parameterName);

                // Check if there's an update to the parameter. If not, just return the parameter as-is.
                final ParameterUpdate parameterUpdate = updatedParameters.get(parameterName);
                if (parameterUpdate == null) {
                    return optionalParameter;
                }

                // There is an update to the parameter. We want to return the previous value of the Parameter.
                final ParameterDescriptor parameterDescriptor;
                final boolean isProvided;
                if (optionalParameter.isPresent()) {
                    final Parameter previousParameter = optionalParameter.get();
                    parameterDescriptor = previousParameter.getDescriptor();
                    isProvided = previousParameter.isProvided();
                } else {
                    parameterDescriptor = new ParameterDescriptor.Builder()
                        .name(parameterName)
                        .description("")
                        .sensitive(true)
                        .build();
                    isProvided = false;
                }

                final Parameter updatedParameter = new Parameter(parameterDescriptor, parameterUpdate.getPreviousValue(), null, isProvided);
                return Optional.of(updatedParameter);
            }

            @Override
            public boolean isEmpty() {
                return (currentContext == null || currentContext.isEmpty()) && updatedParameters.isEmpty();
            }

            @Override
            public long getVersion() {
                return 0;
            }
        };
    }

    @Override
    public ValidationStatus getValidationStatus() {
        return validationState.get().getStatus();
    }

    @Override
    public ValidationStatus getValidationStatus(long timeout, TimeUnit timeUnit) {
        long millis = timeUnit.toMillis(timeout);
        final long maxTime = System.currentTimeMillis() + millis;

        synchronized (validationState) {
            while (getValidationStatus() == ValidationStatus.VALIDATING) {
                try {
                    final long waitMillis = Math.max(0, maxTime - System.currentTimeMillis());
                    if (waitMillis <= 0) {
                        break;
                    }

                    validationState.wait(waitMillis);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return getValidationStatus();
                }
            }

            return getValidationStatus();
        }
    }

    protected ValidationState getValidationState() {
        return validationState.get();
    }

    private boolean replaceValidationState(final ValidationState expectedState, final ValidationState newState) {
        synchronized (validationState) {
            if (validationState.compareAndSet(expectedState, newState)) {
                validationState.notifyAll();
                return true;
            }

            return false;
        }
    }

    @Override
    public void resetValidationState() {
        lock.lock();
        try {
            validationContext = null;
            validationState.set(new ValidationState(ValidationStatus.VALIDATING, Collections.emptyList()));

            if (isTriggerValidation()) {
                validationTrigger.triggerAsync(this);
            } else {
                logger.debug("Reset validation state of {} but will not trigger async validation because trigger has been paused", this);
            }
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void pauseValidationTrigger() {
        triggerValidation = false;
    }

    @Override
    public void resumeValidationTrigger() {
        triggerValidation = true;

        logger.debug("Resuming Triggering of Validation State for {}; Resetting validation state", this);
        resetValidationState();
    }

    private boolean isTriggerValidation() {
        return triggerValidation;
    }

    @Override
    public Collection<ValidationResult> getValidationErrors() {
        return getValidationErrors(Collections.emptySet());
    }

    protected Collection<ValidationResult> getValidationErrors(final Set<ControllerServiceNode> servicesToIgnore) {
        final ValidationState validationState = this.validationState.get();
        if (validationState.getStatus() == ValidationStatus.VALIDATING) {
            return null;
        }

        final Collection<ValidationResult> validationErrors = validationState.getValidationErrors();
        if (servicesToIgnore == null || servicesToIgnore.isEmpty()) {
            return validationErrors;
        }

        final Set<String> ignoredServiceIds = servicesToIgnore.stream()
            .map(ControllerServiceNode::getIdentifier)
            .collect(Collectors.toSet());

        final List<ValidationResult> retainedValidationErrors = new ArrayList<>();
        for (final ValidationResult result : validationErrors) {
            if (!(result instanceof DisabledServiceValidationResult)) {
                retainedValidationErrors.add(result);
                continue;
            }

            final String serviceId = ((DisabledServiceValidationResult) result).getControllerServiceIdentifier();
            if (!ignoredServiceIds.contains(serviceId)) {
                retainedValidationErrors.add(result);
            }
        }

        return retainedValidationErrors;
    }

    public abstract void verifyModifiable() throws IllegalStateException;

    /**
     *
     */
    ControllerServiceProvider getControllerServiceProvider() {
        return this.serviceProvider;
    }

    @Override
    public String getCanonicalClassName() {
        return componentCanonicalClass;
    }

    @Override
    public String getComponentType() {
        return componentType;
    }

    protected ValidationContextFactory getValidationContextFactory() {
        return this.validationContextFactory;
    }

    protected ValidationContext getValidationContext() {
        lock.lock();
        try {
            ValidationContext context = this.validationContext;
            if (context != null) {
                return context;
            }

            boolean cacheValidationContext = true;
            if (getComponent() instanceof final AsyncLoadedProcessor asyncLoadedProcessor) {
                cacheValidationContext = asyncLoadedProcessor.isLoaded();
            }

            context = getValidationContextFactory().newValidationContext(getProperties(), getAnnotationData(), getProcessGroupIdentifier(), getIdentifier(), getParameterContext(), true);

            if (cacheValidationContext) {
                this.validationContext = context;
                logger.debug("Updating validation context to {}", context);
            }

            return context;
        } finally {
            lock.unlock();
        }
    }

    protected ReloadComponent getReloadComponent() {
        return this.reloadComponent;
    }

    protected ExtensionManager getExtensionManager() {
        return this.extensionManager;
    }

    @Override
    public void verifyCanUpdateBundle(final BundleCoordinate incomingCoordinate) throws IllegalArgumentException {
        final BundleCoordinate existingCoordinate = getBundleCoordinate();

        // determine if this update is changing the bundle for the processor
        if (!existingCoordinate.equals(incomingCoordinate)) {
            // if it is changing the bundle, only allow it to change to a different version within same group and id
            if (!existingCoordinate.getGroup().equals(incomingCoordinate.getGroup())
                    || !existingCoordinate.getId().equals(incomingCoordinate.getId())) {
                throw new IllegalArgumentException(String.format(
                        "Unable to update component %s from %s to %s because bundle group and id must be the same.",
                        getIdentifier(), existingCoordinate.getCoordinate(), incomingCoordinate.getCoordinate()));
            }
        }
    }

    protected void setAdditionalResourcesFingerprint(String additionalResourcesFingerprint) {
        this.additionalResourcesFingerprint = additionalResourcesFingerprint;
    }

    // Determine whether the property value should be evaluated in terms of the parameter context or not.
    // If the sensitivity of the property does not match the sensitivity of the parameter, the literal value will be returned
    //
    // Examples when SensitiveParam value = 'abc' and MY_PROP is non-sensitive:
    // SensitiveProp            --> 'abc'
    // NonSensitiveProp         --> '#{SensitiveParam}'
    // context.getProperty(MY_PROP).getValue(); '#{SensitiveParam}'
    private boolean isResolveParameter(final PropertyDescriptor descriptor, final PropertyConfiguration config) {
        boolean okToResolve = true;

        final ParameterContext context = getParameterContext();
        if (context == null) {
            return false;
        }
        for (final ParameterReference reference : config.getParameterReferences()) {
            final String parameterName = reference.getParameterName();
            final Optional<Parameter> optionalParameter = context.getParameter(parameterName);
            if (optionalParameter.isPresent()) {
                final boolean paramIsSensitive = optionalParameter.get().getDescriptor().isSensitive();
                if (paramIsSensitive != descriptor.isSensitive()) {
                    okToResolve = false;
                    break;
                }
            }
        }
        return okToResolve;
    }

    // Evaluates the parameter value if it is ok to do so, otherwise return the raw "${param}" literal.
    // This is done to prevent evaluation of a sensitive parameter when setting a non-sensitive property.
    private String getConfigValue(final PropertyConfiguration config, final boolean okToResolve) {
        return okToResolve ? config.getEffectiveValue(getParameterContext()) : config.getRawValue();
    }

    protected abstract ParameterContext getParameterContext();

    @Override
    public boolean isReferencingParameter(final String parameterName) {
        return parameterReferenceCounts.containsKey(parameterName);
    }

}
