/*
 * Copyright (c) 2021 the Eclipse Milo Authors
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */

package org.eclipse.milo.opcua.sdk.server.nodes;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;

import com.google.common.collect.ImmutableList;
import org.eclipse.milo.opcua.sdk.core.QualifiedProperty;
import org.eclipse.milo.opcua.sdk.core.Reference;
import org.eclipse.milo.opcua.sdk.core.nodes.Node;
import org.eclipse.milo.opcua.sdk.core.nodes.ObjectNode;
import org.eclipse.milo.opcua.sdk.core.nodes.VariableNode;
import org.eclipse.milo.opcua.sdk.server.api.NodeManager;
import org.eclipse.milo.opcua.sdk.server.model.nodes.variables.PropertyTypeNode;
import org.eclipse.milo.opcua.sdk.server.nodes.delegates.AttributeDelegate;
import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilterChain;
import org.eclipse.milo.opcua.stack.core.AttributeId;
import org.eclipse.milo.opcua.stack.core.Identifiers;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.UaRuntimeException;
import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort;
import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass;
import org.jetbrains.annotations.Nullable;

import static org.eclipse.milo.opcua.sdk.core.util.StreamUtil.opt2stream;
import static org.eclipse.milo.opcua.sdk.server.util.AttributeUtil.dv;
import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;

public abstract class UaNode implements UaServerNode {

    private AttributeDelegate attributeDelegate;
    private List<AttributeObserver> observers;

    final AttributeFilterChain filterChain = new AttributeFilterChain();

    private final UaNodeContext context;

    private NodeId nodeId;
    private NodeClass nodeClass;
    private QualifiedName browseName;
    private LocalizedText displayName;
    private LocalizedText description;
    private UInteger writeMask;
    private UInteger userWriteMask;

    protected UaNode(
        UaNodeContext context,
        NodeId nodeId,
        NodeClass nodeClass,
        QualifiedName browseName,
        LocalizedText displayName) {

        this(context, nodeId, nodeClass, browseName,
            displayName, LocalizedText.NULL_VALUE, UInteger.MIN, UInteger.MIN);
    }

    protected UaNode(
        UaNodeContext context,
        NodeId nodeId,
        NodeClass nodeClass,
        QualifiedName browseName,
        LocalizedText displayName,
        LocalizedText description,
        UInteger writeMask,
        UInteger userWriteMask) {

        this.context = context;

        this.nodeId = nodeId;
        this.nodeClass = nodeClass;
        this.browseName = browseName;
        this.displayName = displayName;
        this.description = description;
        this.writeMask = writeMask;
        this.userWriteMask = userWriteMask;
    }

    @Override
    public NodeId getNodeId() {
        return (NodeId) filterChain.getAttribute(this, AttributeId.NodeId);
    }

    @Override
    public NodeClass getNodeClass() {
        return (NodeClass) filterChain.getAttribute(this, AttributeId.NodeClass);
    }

    @Override
    public QualifiedName getBrowseName() {
        return (QualifiedName) filterChain.getAttribute(this, AttributeId.BrowseName);
    }

    @Override
    public LocalizedText getDisplayName() {
        return (LocalizedText) filterChain.getAttribute(this, AttributeId.DisplayName);
    }

    @Override
    public LocalizedText getDescription() {
        return (LocalizedText) filterChain.getAttribute(this, AttributeId.Description);
    }

    @Override
    public UInteger getWriteMask() {
        return (UInteger) filterChain.getAttribute(this, AttributeId.WriteMask);
    }

    @Override
    public UInteger getUserWriteMask() {
        return (UInteger) filterChain.getAttribute(this, AttributeId.UserWriteMask);
    }

    @Override
    public void setNodeId(NodeId nodeId) {
        filterChain.setAttribute(this, AttributeId.NodeId, nodeId);
    }

    @Override
    public void setNodeClass(NodeClass nodeClass) {
        filterChain.setAttribute(this, AttributeId.NodeClass, nodeClass);
    }

    @Override
    public void setBrowseName(QualifiedName browseName) {
        filterChain.setAttribute(this, AttributeId.BrowseName, browseName);
    }

    @Override
    public void setDisplayName(LocalizedText displayName) {
        filterChain.setAttribute(this, AttributeId.DisplayName, displayName);
    }

    @Override
    public void setDescription(LocalizedText description) {
        filterChain.setAttribute(this, AttributeId.Description, description);
    }

    @Override
    public void setWriteMask(UInteger writeMask) {
        filterChain.setAttribute(this, AttributeId.WriteMask, writeMask);
    }

    @Override
    public void setUserWriteMask(UInteger userWriteMask) {
        filterChain.setAttribute(this, AttributeId.UserWriteMask, userWriteMask);
    }

    /**
     * Direct read access to the field for {@code attributeId}, bypassing the {@link AttributeFilterChain}.
     *
     * @param attributeId the {@link AttributeId} to get the value for.
     * @return the value for {@code attributeId}.
     */
    public synchronized Object getAttribute(AttributeId attributeId) {
        switch (attributeId) {
            case NodeId:
                return nodeId;

            case NodeClass:
                return nodeClass;

            case BrowseName:
                return browseName;

            case DisplayName:
                return displayName;

            case Description:
                return description;

            case WriteMask:
                return writeMask;

            case UserWriteMask:
                return userWriteMask;

            default:
                throw new UaRuntimeException(
                    StatusCodes.Bad_AttributeIdInvalid,
                    "AttributeId: " + attributeId
                );
        }
    }

    /**
     * Direct write access to the field for {@code attributeId}, bypassing the {@link AttributeFilterChain}.
     * <p>
     * Setting an attribute value invokes {@link #fireAttributeChanged(AttributeId, Object)}, notifying any registered
     * {@link AttributeObserver}s of the change.
     *
     * @param attributeId the {@link AttributeId} to set the value for.
     * @param value       the value to set.
     */
    public synchronized void setAttribute(AttributeId attributeId, Object value) {
        switch (attributeId) {
            case NodeId:
                nodeId = (NodeId) value;
                break;

            case NodeClass:
                nodeClass = (NodeClass) value;
                break;

            case BrowseName:
                browseName = (QualifiedName) value;
                break;

            case DisplayName:
                displayName = (LocalizedText) value;
                break;

            case Description:
                description = (LocalizedText) value;
                break;

            case WriteMask:
                writeMask = (UInteger) value;
                break;

            case UserWriteMask:
                userWriteMask = (UInteger) value;
                break;

            default:
                throw new UaRuntimeException(
                    StatusCodes.Bad_AttributeIdInvalid,
                    "AttributeId: " + attributeId
                );
        }

        fireAttributeChanged(attributeId, value);
    }

    /**
     * Delete this Node, its References, and all its child Nodes and their References, recursively.
     */
    public final void delete() {
        NodeManager<UaNode> nodeManager = context.getNodeManager();

        nodeManager.removeNode(getNodeId());

        for (Reference reference : nodeManager.getReferences(getNodeId())) {
            if (reference.isForward() && reference.subtypeOf(Identifiers.HasChild)) {
                Optional<UaNode> targetNode = nodeManager.getNode(
                    reference.getTargetNodeId(),
                    getNodeContext().getServer().getNamespaceTable()
                );

                targetNode.ifPresent(UaNode::delete);
            }

            nodeManager.removeReferences(reference, context.getNamespaceTable());
        }
    }

    @Override
    public final UaNodeContext getNodeContext() {
        return context;
    }

    public final NodeManager<UaNode> getNodeManager() {
        return context.getNodeManager();
    }

    protected Optional<UaNode> getManagedNode(NodeId nodeId) {
        //return getNodeManager(nodeId).flatMap(n -> n.getNode(nodeId));
        return context.getServer().getAddressSpaceManager().getManagedNode(nodeId);
    }

    protected Optional<UaNode> getManagedNode(ExpandedNodeId nodeId) {
        //return getNodeManager(nodeId).flatMap(n -> n.getNode(nodeId));
        return context.getServer().getAddressSpaceManager().getManagedNode(nodeId);
    }

    @Override
    public ImmutableList<Reference> getReferences() {
        return ImmutableList.copyOf(
            context.getServer()
                .getAddressSpaceManager()
                .getManagedReferences(getNodeId())
        );
    }

    /**
     * Add {@code reference} and its inverse.
     *
     * @param reference the {@link Reference} to add.
     */
    @Override
    public void addReference(Reference reference) {
        context.getNodeManager().addReferences(reference, context.getNamespaceTable());
    }

    /**
     * Remove {@code reference} and its inverse.
     *
     * @param reference the {@link Reference} to remove.
     */
    @Override
    public void removeReference(Reference reference) {
        context.getNodeManager().removeReferences(reference, context.getNamespaceTable());
    }

    public <T> Optional<T> getProperty(QualifiedProperty<T> property) {
        String namespaceUri = property.getNamespaceUri();

        UShort namespaceIndex = context.getServer().getNamespaceTable().getIndex(namespaceUri);

        if (namespaceIndex != null) {
            QualifiedName browseName = new QualifiedName(
                namespaceIndex,
                property.getBrowseName()
            );

            try {
                return getProperty(browseName)
                    .map(property.getJavaType()::cast);
            } catch (Throwable t) {
                return Optional.empty();
            }
        } else {
            return Optional.empty();
        }
    }

    public Optional<Object> getProperty(QualifiedName browseName) {
        return getPropertyNode(browseName)
            .map(node -> node.getValue().getValue().getValue());
    }

    public <T> void setProperty(QualifiedProperty<T> property, T value) {
        UShort namespaceIndex = context.getServer()
            .getNamespaceTable().getIndex(property.getNamespaceUri());

        if (namespaceIndex == null) {
            throw new IllegalArgumentException(
                "property belongs to unregistered " +
                    "namespace: " + property.getNamespaceUri());
        }

        VariableNode node = getPropertyNode(property).orElseGet(() -> {
            String browseName = property.getBrowseName();

            NodeId propertyNodeId = new NodeId(
                getNodeId().getNamespaceIndex(),
                String.format("%s.%s", getNodeId().getIdentifier().toString(), browseName)
            );

            PropertyTypeNode propertyNode = new PropertyTypeNode(
                context,
                propertyNodeId,
                new QualifiedName(namespaceIndex, browseName),
                LocalizedText.english(browseName),
                LocalizedText.NULL_VALUE,
                uint(0),
                uint(0)
            );

            NodeId dataType = property.getDataType()
                .toNodeId(context.getNamespaceTable())
                .orElse(NodeId.NULL_VALUE);

            propertyNode.setDataType(dataType);
            propertyNode.setValueRank(property.getValueRank());
            propertyNode.setArrayDimensions(property.getArrayDimensions());

            propertyNode.addReference(new Reference(
                propertyNode.getNodeId(),
                Identifiers.HasTypeDefinition,
                Identifiers.PropertyType.expanded(),
                true
            ));

            addProperty(propertyNode);

            context.getNodeManager().addNode(propertyNode);

            return propertyNode;
        });

        node.setValue(new DataValue(new Variant(value)));
    }

    public Optional<VariableNode> getPropertyNode(QualifiedProperty<?> property) {
        Optional<QualifiedName> qualifiedName = property
            .getQualifiedName(context.getServer().getNamespaceTable());

        return qualifiedName.map(this::getPropertyNode)
            .orElseGet(() -> getPropertyNode(property.getBrowseName()));
    }

    public Optional<VariableNode> getPropertyNode(String browseName) {
        return getPropertyNode(new QualifiedName(getNodeId().getNamespaceIndex(), browseName));
    }

    public Optional<VariableNode> getPropertyNode(QualifiedName browseName) {
        Node node = getReferences()
            .stream()
            .filter(Reference.HAS_PROPERTY_PREDICATE)
            .flatMap(r -> opt2stream(getManagedNode(r.getTargetNodeId())))
            .filter(n -> n.getBrowseName().equals(browseName))
            .findFirst().orElse(null);

        try {
            return Optional.ofNullable((VariableNode) node);
        } catch (Throwable t) {
            return Optional.empty();
        }
    }

    void addProperty(UaVariableNode node) {
        addReference(new Reference(
            getNodeId(),
            Identifiers.HasProperty,
            node.getNodeId().expanded(),
            true
        ));

        node.addReference(new Reference(
            node.getNodeId(),
            Identifiers.HasProperty,
            getNodeId().expanded(),
            false
        ));
    }

    void removeProperty(UaVariableNode node) {
        removeReference(new Reference(
            getNodeId(),
            Identifiers.HasProperty,
            node.getNodeId().expanded(),
            true
        ));

        node.removeReference(new Reference(
            node.getNodeId(),
            Identifiers.HasProperty,
            getNodeId().expanded(),
            false
        ));
    }

    /**
     * Find a {@link UaNode} with the specified {@code browseName} referenced by this node.
     *
     * @param browseName the Browse Name of the target node.
     * @return the target node, if one was found.
     */
    public Optional<UaNode> findNode(QualifiedName browseName) {
        return findNode(browseName, uaNode -> true, reference -> true);
    }

    /**
     * Find a {@link UaNode} with the specified {@code browseName} referenced by this node.
     *
     * @param browseName the Browse Name of the target node.
     * @param references a {@link Predicate} used to include/exclude references to follow.
     * @return the target node, if one was found.
     */
    public Optional<UaNode> findNode(QualifiedName browseName, Predicate<Reference> references) {
        return findNode(browseName, uaNode -> true, references);
    }

    /**
     * Find a {@link UaNode} with the specified {@code browseName} referenced by this node.
     *
     * @param browseName         the Browse Name of the target node.
     * @param nodePredicate      a {@link Predicate} used to include/exclude target Nodes.
     * @param referencePredicate a {@link Predicate} used to include/exclude references to follow.
     * @return the target node, if one was found.
     */
    public Optional<UaNode> findNode(
        QualifiedName browseName,
        Predicate<UaNode> nodePredicate,
        Predicate<Reference> referencePredicate
    ) {

        return getReferences()
            .stream()
            .filter(referencePredicate)
            .flatMap(r -> opt2stream(getManagedNode(r.getTargetNodeId())))
            .filter(nodePredicate)
            .filter(n -> n.getBrowseName().equals(browseName))
            .findFirst();
    }

    /**
     * Find a {@link UaNode} with a Browse Name of {@code browseName}, in {@code namespaceUri}, referenced by this node.
     *
     * @param namespaceUri       the namespace URI of {@code browseName}.
     * @param browseName         the Browse Name of the target node.
     * @param nodePredicate      a {@link Predicate} used to include/exclude target Nodes.
     * @param referencePredicate a {@link Predicate} used to include/exclude references to follow.
     * @return the target node, if one was found.
     */
    public Optional<UaNode> findNode(
        String namespaceUri,
        String browseName,
        Predicate<UaNode> nodePredicate,
        Predicate<Reference> referencePredicate) {

        return getReferences()
            .stream()
            .filter(referencePredicate)
            .flatMap(r -> opt2stream(getManagedNode(r.getTargetNodeId())))
            .filter(nodePredicate)
            .filter(n -> {
                String nodeBrowseName = n.getBrowseName().getName();

                UShort index = n.getBrowseName().getNamespaceIndex();
                String nodeBrowseNameUri = context.getServer().getNamespaceTable().getUri(index);

                return Objects.equals(browseName, nodeBrowseName) && Objects.equals(namespaceUri, nodeBrowseNameUri);
            })
            .findFirst();
    }

    protected Optional<ObjectNode> getObjectComponent(String namespaceUri, String name) {
        UShort namespaceIndex = context.getServer().getNamespaceTable().getIndex(namespaceUri);

        if (namespaceIndex != null) {
            return getObjectComponent(new QualifiedName(namespaceIndex, name));
        } else {
            return Optional.empty();
        }
    }

    protected Optional<ObjectNode> getObjectComponent(String browseName) {
        return getObjectComponent(new QualifiedName(getNodeId().getNamespaceIndex(), browseName));
    }

    protected Optional<ObjectNode> getObjectComponent(QualifiedName browseName) {
        ObjectNode node = (ObjectNode) getReferences()
            .stream()
            .filter(Reference.HAS_COMPONENT_PREDICATE)
            .flatMap(r -> opt2stream(getManagedNode(r.getTargetNodeId())))
            .filter(n -> n.getNodeClass() == NodeClass.Object && n.getBrowseName().equals(browseName))
            .findFirst().orElse(null);

        return Optional.ofNullable(node);
    }

    protected Optional<VariableNode> getVariableComponent(String namespaceUri, String name) {
        UShort namespaceIndex = context.getServer().getNamespaceTable().getIndex(namespaceUri);

        if (namespaceIndex != null) {
            return getVariableComponent(new QualifiedName(namespaceIndex, name));
        } else {
            return Optional.empty();
        }
    }

    protected Optional<VariableNode> getVariableComponent(String browseName) {
        return getVariableComponent(new QualifiedName(getNodeId().getNamespaceIndex(), browseName));
    }

    protected Optional<VariableNode> getVariableComponent(QualifiedName browseName) {
        VariableNode node = (VariableNode) getReferences()
            .stream()
            .filter(Reference.HAS_COMPONENT_PREDICATE)
            .flatMap(r -> opt2stream(getManagedNode(r.getTargetNodeId())))
            .filter(n -> n.getNodeClass() == NodeClass.Variable && n.getBrowseName().equals(browseName))
            .findFirst().orElse(null);

        return Optional.ofNullable(node);
    }

    public synchronized void addAttributeObserver(AttributeObserver observer) {
        if (observers == null) {
            observers = new LinkedList<>();
        }

        observers.add(observer);
    }

    public synchronized void removeAttributeObserver(AttributeObserver observer) {
        if (observers == null) return;

        observers.remove(observer);

        if (observers.isEmpty()) observers = null;
    }

    public synchronized void fireAttributeChanged(AttributeId attributeId, Object attributeValue) {
        if (observers == null) return;

        List<AttributeObserver> toNotify = new ArrayList<>(observers);
        toNotify.forEach(o -> o.attributeChanged(this, attributeId, attributeValue));
    }

    /**
     * Get the {@link AttributeFilterChain} for this Node.
     *
     * @return the {@link AttributeFilterChain} for this Node.
     */
    public AttributeFilterChain getFilterChain() {
        return filterChain;
    }

    /**
     * Set the {@link AttributeDelegate} for this node.
     *
     * @param attributeDelegate the {@link AttributeDelegate}.
     */
    public synchronized void setAttributeDelegate(AttributeDelegate attributeDelegate) {
        this.attributeDelegate = attributeDelegate;
    }

    /**
     * Get the current {@link AttributeDelegate} for this node.
     *
     * @return the current {@link AttributeDelegate} for this node.
     */
    @Nullable
    public synchronized AttributeDelegate getAttributeDelegate() {
        return attributeDelegate;
    }

    @Override
    public DataValue getAttribute(AttributeContext context, AttributeId attributeId) {
        AttributeDelegate delegate = getAttributeDelegate();

        if (delegate == null) {
            try {
                Object attributeValue = getFilterChain().getAttribute(
                    context.getSession().orElse(null),
                    this,
                    attributeId
                );

                if (attributeId == AttributeId.Value) {
                    return (DataValue) attributeValue;
                } else {
                    return dv(attributeValue);
                }
            } catch (Throwable t) {
                StatusCode statusCode = UaException.extractStatusCode(t)
                    .orElse(new StatusCode(StatusCodes.Bad_InternalError));

                return new DataValue(statusCode);
            }
        } else {
            return delegate.getAttribute(context, this, attributeId);
        }
    }

    @Override
    public void setAttribute(
        AttributeContext context,
        AttributeId attributeId,
        DataValue value
    ) throws UaException {

        AttributeDelegate delegate = getAttributeDelegate();

        if (delegate == null) {
            try {
                getFilterChain().setAttribute(
                    context.getSession().orElse(null),
                    this,
                    attributeId,
                    attributeId == AttributeId.Value ? value : value.getValue().getValue()
                );
            } catch (Throwable t) {
                long statusCode = UaException.extractStatusCode(t)
                    .map(StatusCode::getValue)
                    .orElse(StatusCodes.Bad_InternalError);

                throw new UaException(statusCode, t);
            }
        } else {
            delegate.setAttribute(context, this, attributeId, value);
        }
    }

}
