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

import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;

import com.google.common.base.MoreObjects;
import org.eclipse.milo.opcua.stack.core.BuiltinReferenceType;
import org.eclipse.milo.opcua.stack.core.Identifiers;
import org.eclipse.milo.opcua.stack.core.NamespaceTable;
import org.eclipse.milo.opcua.stack.core.ReferenceType;
import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
import org.slf4j.LoggerFactory;

public class Reference {

    public enum Direction {
        FORWARD,
        INVERSE
    }

    private final NodeId sourceNodeId;
    private final NodeId referenceTypeId;
    private final ExpandedNodeId targetNodeId;
    private final Direction direction;

    public Reference(
        NodeId sourceNodeId,
        NodeId referenceTypeId,
        ExpandedNodeId targetNodeId,
        boolean forward) {

        this(
            sourceNodeId,
            referenceTypeId,
            targetNodeId,
            forward ? Direction.FORWARD : Direction.INVERSE);
    }

    public Reference(
        NodeId sourceNodeId,
        NodeId referenceTypeId,
        ExpandedNodeId targetNodeId,
        Direction direction) {

        this.sourceNodeId = sourceNodeId;
        this.referenceTypeId = referenceTypeId;
        this.targetNodeId = targetNodeId;
        this.direction = direction;
    }

    public NodeId getSourceNodeId() {
        return sourceNodeId;
    }

    public NodeId getReferenceTypeId() {
        return referenceTypeId;
    }

    public ExpandedNodeId getTargetNodeId() {
        return targetNodeId;
    }

    public Direction getDirection() {
        return direction;
    }

    public boolean isForward() {
        return direction == Direction.FORWARD;
    }

    public boolean isInverse() {
        return direction == Direction.INVERSE;
    }

    /**
     * Return an inverted instance of this Reference so long as the target NodeId resides within this server.
     *
     * @return an inverted instance of this Reference so long as the target NodeId resides within this server.
     */
    public Optional<Reference> invert(NamespaceTable namespaceTable) {
        return getTargetNodeId().toNodeId(namespaceTable).map(
            sourceNodeId -> new Reference(
                sourceNodeId,
                getReferenceTypeId(),
                getSourceNodeId().expanded(),
                !isForward()
            )
        );
    }

    /**
     * Re-index the source, target, and reference type {@link NodeId}s in this {@link Reference} from their current
     * namespace index to the index for {@code namespaceUri}.
     * <p>
     * If the target namespace URI is not present in the namespace table this {@link Reference} is returned.
     *
     * @param namespaceTable        the {@link NamespaceTable}.
     * @param sourceNamespaceUri    the target namespace URI for the source NodeId.
     * @param referenceNamespaceUri the target namespace URI for the reference type NodeId.
     * @param targetNamespaceUri    the target namespace URI for the target NodeId.
     * @return a new {@link NodeId} in the namespace index indicated by {@code namespaceUri}.
     */
    public Reference reindex(
        NamespaceTable namespaceTable,
        String sourceNamespaceUri,
        String referenceNamespaceUri,
        String targetNamespaceUri
    ) {

        NodeId newSourceNodeId = sourceNodeId.reindex(namespaceTable, sourceNamespaceUri);

        NodeId newReferenceTypeId = referenceTypeId.reindex(namespaceTable, referenceNamespaceUri);

        // re-index targetNodeId only if it's local, otherwise leave it alone.
        ExpandedNodeId newTargetNodeId = targetNodeId.toNodeId(namespaceTable)
            .map(id -> id.reindex(namespaceTable, targetNamespaceUri).expanded())
            .orElse(targetNodeId);

        return new Reference(
            newSourceNodeId,
            newReferenceTypeId,
            newTargetNodeId,
            direction
        );
    }

    /**
     * Check if this reference is a subtype of the built-in reference identified by {@code superTypeId}.
     *
     * @param superTypeId the {@link NodeId} of the supertype.
     * @return {@code true} if this reference is a subtype of the built-in reference type identified by
     * {@code superTypeId}.
     * @see BuiltinReferenceType
     */
    public boolean subtypeOf(NodeId superTypeId) {
        return subtypeOf(superTypeId, BuiltinReferenceType.getReferenceMap());
    }

    public boolean subtypeOf(NodeId superTypeId, Map<NodeId, ReferenceType> referenceTypes) {
        return subtypeOf(referenceTypeId, superTypeId, referenceTypes);
    }

    private boolean subtypeOf(NodeId typeId, NodeId superTypeId, Map<NodeId, ReferenceType> referenceTypes) {
        ReferenceType referenceType = referenceTypes.get(typeId);

        if (referenceType == null) {
            LoggerFactory.getLogger(getClass()).warn("Unknown reference type: {}", typeId);
            return false;
        }

        return referenceType.getSuperTypeId()
            .map(id -> id.equals(superTypeId) || subtypeOf(id, superTypeId, referenceTypes))
            .orElse(false);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Reference reference = (Reference) o;
        return Objects.equals(sourceNodeId, reference.sourceNodeId) &&
            Objects.equals(referenceTypeId, reference.referenceTypeId) &&
            Objects.equals(targetNodeId, reference.targetNodeId) &&
            direction == reference.direction;
    }

    @Override
    public int hashCode() {
        return Objects.hash(sourceNodeId, referenceTypeId, targetNodeId, direction);
    }

    @Override
    public String toString() {
        return MoreObjects.toStringHelper(this)
            .add("sourceNodeId", sourceNodeId)
            .add("referenceTypeId", referenceTypeId)
            .add("targetNodeId", targetNodeId)
            .add("direction", direction)
            .toString();
    }

    public static final Predicate<Reference> HAS_COMPONENT_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasComponent.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_ORDERED_COMPONENT_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasOrderedComponent.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> COMPONENT_OF_PREDICATE =
        (reference) -> reference.isInverse() && Identifiers.HasComponent.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> ORDERED_COMPONENT_OF_PREDICATE =
        (reference) -> reference.isInverse() && Identifiers.HasOrderedComponent.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_PROPERTY_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasProperty.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_TYPE_DEFINITION_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasTypeDefinition.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_EVENT_SOURCE_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasEventSource.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_NOTIFIER_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasNotifier.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> ORGANIZES_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.Organizes.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_ENCODING_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasEncoding.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_DESCRIPTION_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasDescription.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_MODELLING_RULE_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.HasModellingRule.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> ALWAYS_GENERATES_EVENT_PREDICATE =
        (reference) -> reference.isForward() && Identifiers.AlwaysGeneratesEvent.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> HAS_SUBTYPE =
        (reference) -> reference.isForward() && Identifiers.HasSubtype.equals(reference.getReferenceTypeId());

    public static final Predicate<Reference> SUBTYPE_OF =
        (reference) -> reference.isInverse() && Identifiers.HasSubtype.equals(reference.getReferenceTypeId());

}
