/**
 * This file is part of the Meeds project (https://meeds.io/).
 *
 * Copyright (C) 2020 - 2025 Meeds Association contact@meeds.io
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 */
package org.gatein.api.navigation;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.exoplatform.commons.utils.ExpressionUtil;
import org.exoplatform.portal.mop.State;
import org.exoplatform.portal.mop.navigation.NodeChange;
import org.exoplatform.portal.mop.navigation.NodeContext;
import org.exoplatform.portal.mop.navigation.NodeState;
import org.exoplatform.portal.mop.navigation.NodeState.Builder;
import org.gatein.api.EntityAlreadyExistsException;
import org.gatein.api.EntityNotFoundException;
import org.gatein.api.Portal;
import org.gatein.api.PortalRequest;
import org.gatein.api.Util;
import org.gatein.api.common.i18n.LocalizedString;
import org.gatein.api.internal.ObjectToStringBuilder;
import org.gatein.api.internal.Parameters;
import org.gatein.api.navigation.Visibility.Status;
import org.gatein.api.page.PageId;
import org.gatein.api.site.SiteId;

public class ApiNode implements Node {
    transient NodeContext<ApiNode> context;
    transient NavigationImpl navigation;

    private LocalizedString displayName;
    private String resolvedDisplayName;
    private String resolvedURI;
    private boolean displayNameChanged;

    private final SiteId siteId;

    ApiNode(NavigationImpl navigation, NodeContext<ApiNode> context) {
        this.navigation = navigation;
        this.siteId = navigation.getSiteId();
        this.context = context;
    }

    @Override
    public Node addChild(int index, String childName) {
        checkChildrenLoaded();

        Parameters.requireNonNull(childName, "childName");

        return context.add(index, childName).getNode();
    }

    @Override
    public Node addChild(String childName) {
        checkChildrenLoaded();

        Parameters.requireNonNull(childName, "childName");

        if (hasChild(childName)) {
            throw new EntityAlreadyExistsException("child " + childName + " already exists");
        }

        return context.add(null, childName).getNode();
    }

    @Override
    public FilteredNode filter() {
        return new ApiFilteredNode(navigation, context);
    }

    @Override
    public Node getChild(int index) {
        checkChildrenLoaded();

        return context.isExpanded() ? context.getNode(index) : null;
    }

    @Override
    public Node getChild(String childName) {
        Parameters.requireNonNull(childName, "childName");

        checkChildrenLoaded();

        return context.isExpanded() ? context.getNode(childName) : null;
    }

    @Override
    public int getChildCount() {
        checkChildrenLoaded();

        return context.getNodeSize();
    }

    @Override
    public Node getNode(String... nodePath) {
        return getNode(NodePath.path(nodePath));
    }

    @Override
    public Node getNode(NodePath nodePath) {
        checkChildrenLoaded();

        Parameters.requireNonNull(nodePath, "nodePath");

        Node node = this;
        for (String name : nodePath) {
            node = node.getChild(name);
            if (node == null)
                return null;
        }

        return node;
    }

    @Override
    public String getIconName() {
        return context.getState().getIcon();
    }

    @Override
    public String getDisplayName() {
        if (resolvedDisplayName == null) {
            // Avoid any resolving if we're dealing with a simple non resource-bundle label
            String simple = context.getState().getLabel();
            if (simple != null && !ExpressionUtil.isResourceBindingExpression(simple)) {
                resolvedDisplayName = simple;
            } else {
                resolvedDisplayName = navigation.resolve(context);
            }
        }

        return resolvedDisplayName;
    }

    @Override
    public LocalizedString getDisplayNames() {
        if (displayName == null) {
            String simple = context.getState().getLabel();
            if (simple != null) {
                displayName = new LocalizedString(simple);
            } else if (context.getId() != null) {
                Map<Locale, State> descriptions = navigation.loadDescriptions(context.getId());
                displayName = ObjectFactory.createLocalizedString(descriptions);
            }
        }
        return displayName;
    }

    @Override
    public String getName() {
        return isRoot() ? null : context.getName();
    }

    @Override
    public NodePath getNodePath() {
        String name = getName();
        ApiNode parent = context.getParentNode();

        NodePath path = isRoot() ? NodePath.root() : NodePath.path(name);
        if (parent != null) {
            path = parent.getNodePath().append(path);
        }

        return path;
    }

    @Override
    public PageId getPageId() {
        return Util.from(context.getState().getPageRef());
    }

    @Override
    public Node getParent() {
        return context.getParentNode();
    }

    @Override
    public String getURI() {
        if (resolvedURI == null) {
            if (isRoot()) {
                resolvedURI = PortalRequest.getInstance().getURIResolver().resolveURI(siteId);
            } else {
                resolvedURI = getParent().getURI() + "/" + getName();
            }
        }

        return resolvedURI;
    }

    void clearCached() {
        resolvedDisplayName = null;
        resolvedURI = null;
    }

    @Override
    public Visibility getVisibility() {
        return ObjectFactory.createVisibility(context.getState());
    }

    @Override
    public boolean hasChild(String childName) {
        return getChild(childName) != null;
    }

    @Override
    public int indexOf(String childName) {
        ApiNode node = (ApiNode) getChild(childName);
        return node == null ? -1 : node.context.getIndex();
    }

    @Override
    public boolean isChildrenLoaded() {
        return context.isExpanded();
    }

    @Override
    public boolean isRoot() {
        return context.getParent() == null;
    }

    @Override
    public boolean isVisible() {
        return getVisibility().isVisible();
    }

    @Override
    public Iterator<Node> iterator() {
        return new ApiNodeModelIterator();
    }

    @Override
    public void moveTo(int index) {
        checkNonRoot();
        if (context.getIndex() < index) {
            index++;
        }
        context.getParent().add(index, context);
    }

    @Override
    public void moveTo(int index, Node parent) {
        moveTo(new Integer(index), parent);
    }

    @Override
    public void moveTo(Node parent) {
        moveTo(null, parent);
    }

    private void moveTo(Integer index, Node parent) {
        checkNonRoot();

        ((ApiNode) parent).checkChildrenLoaded();

        if (this.getNodePath().isParent(parent.getNodePath())) {
            throw new IllegalArgumentException("Can't move node to a child node of itself");
        }

        Node root = this;
        while (root.getParent() != null) {
            root = root.getParent();
        }

        if (((ApiNode) root.getNode(parent.getNodePath())).getContext() != ((ApiNode) parent).getContext()) {
            throw new IllegalArgumentException("Can't move node to a different branch");
        }

        ((ApiNode) parent).context.add(index, context);
    }

    @Override
    public boolean removeChild(String childName) {
        checkChildrenLoaded();

        if (!hasChild(childName)) {
            throw new EntityNotFoundException("Cannot remove child '" + childName + "' because it does not exist for parent node " + getNodePath());
        }

        return context.removeNode(childName);
    }

    @Override
    public void setName(String name) throws IllegalArgumentException {
        Parameters.requireNonNull(name, "name");

        context.setName(name);
    }

    @Override
    public void setIconName(String iconName) {
        checkNonRoot();

        setState(getStateBuilder().icon(iconName));
    }

    @Override
    public void setDisplayName(String displayName) {
        Parameters.requireNonNull(displayName, "displayName");
        setDisplayNames(new LocalizedString(displayName));
    }

    @Override
    public void setDisplayNames(LocalizedString displayName) {
        checkNonRoot();

        if (displayName == null && this.displayName == null)
            return;

        if (displayName != null || !this.displayName.equals(displayName)) {
            if (displayName != null && !displayName.isLocalized()) {
                setState(getStateBuilder().label(displayName.getValue()));
            } else {
                setState(getStateBuilder().label(null));
            }
            this.displayName = displayName;
            this.resolvedDisplayName = null;
            displayNameChanged = true;
        }
    }

    @Override
    public void setPageId(PageId pageId) {
        checkNonRoot();

        setState(getStateBuilder().pageRef(Util.from(pageId)));
    }

    @Override
    public void setVisibility(boolean visible) {
        checkNonRoot();

        Builder b = getStateBuilder();
        if (visible) {
            b.visibility(org.exoplatform.portal.mop.Visibility.DISPLAYED);
        } else {
            b.visibility(org.exoplatform.portal.mop.Visibility.HIDDEN);
        }
        setState(b);
    }

    @Override
    public void setVisibility(PublicationDate publicationDate) {
        checkNonRoot();

        Parameters.requireNonNull(publicationDate, "publicationDate");

        long start = publicationDate.getStart() != null ? publicationDate.getStart().getTime() : -1;
        long end = publicationDate.getEnd() != null ? publicationDate.getEnd().getTime() : -1;

        setState(getStateBuilder().startPublicationTime(start).endPublicationTime(end)
                .visibility(org.exoplatform.portal.mop.Visibility.TEMPORAL));
    }

    @Override
    public void setVisibility(Visibility visibility) {
        checkNonRoot();

        Parameters.requireNonNull(visibility, "visibility");

        if (visibility.getStatus() == Status.PUBLICATION) {
            setVisibility(visibility.getPublicationDate());
        } else {
            setState(getStateBuilder().startPublicationTime(-1).endPublicationTime(-1)
                    .visibility(ObjectFactory.createVisibility(visibility.getStatus())));
        }
    }

    @Override
    public void sort(Comparator<Node> comparator) {
        Parameters.requireNonNull(comparator, "comparator");

        if (context.isExpanded()) {
            ApiNode[] a = new ApiNode[context.getNodeSize()];
            for (NodeContext<ApiNode> c = context.getFirst(); c != null; c = c.getNext()) {
                a[c.getIndex()] = c.getNode();
            }

            Arrays.sort(a, comparator);

            for (int i = 0; i < a.length; i++) {
                ApiNode n = a[i];
                NodeContext<ApiNode> c = n.getContext();
                if (c.getIndex() != i) {
                    c.getNode().moveTo(i);
                }
            }
        }
    }

    @Override
    public String toString() {
        return ObjectToStringBuilder.toStringBuilder(getClass()).add("name", getName()).add("path", getNodePath())
                .add("visibility", getVisibility()).add("iconName", getIconName()).add("pageId", getPageId()).toString();
    }

    NodeContext<ApiNode> getContext() {
        return context;
    }

    SiteId getSiteId() {
        return siteId;
    }

    boolean isDisplayNameChanged() {
        return displayNameChanged;
    }

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();

        // deserialize serialization only fields
        NodePath nodePath = (NodePath) in.readObject();
        ApiNode parent = (ApiNode) in.readObject();
        boolean expanded = in.readBoolean();

        PortalRequest request = PortalRequest.getInstance();
        Portal portal = (request == null) ? null : request.getPortal();
        if (portal != null) {
            navigation = (NavigationImpl) portal.getNavigation(siteId);
            if (navigation == null)
                throw new IOException("Could not retrieve navigation for site " + siteId);
        } else {
            throw new IOException("Could not retrieve portal API during deserialization.");
        }

        if (parent != null) {
            context = parent.context.get(nodePath.getLastSegment());
            if (expanded) {
                navigation.rebaseNodeContext(context, new NodeVisitorScope(Nodes.visitChildren()), null);
            }
        } else {
            NodeVisitor visitor = (expanded) ? Nodes.visitChildren() : Nodes.visitNone();
            context = navigation.getNodeContext(nodePath, visitor);
        }

        if (expanded && parent == null) {
            MultiPathNodeVisitor visitor = new MultiPathNodeVisitor();
            readTree(visitor, in);
            navigation.rebaseNodeContext(context, new NodeVisitorScope(visitor), null);
        }

        boolean hasChanges = in.readBoolean();
        if (hasChanges && parent == null) { // re-apply changes from root node
            @SuppressWarnings("unchecked")
            List<ApiNodeChange> changes = (List<ApiNodeChange>) in.readObject();
            for (ApiNodeChange change : changes) {
                change.apply(this);
            }
        }
    }

    private void writeObject(java.io.ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();

        // write serialization only fields
        out.writeObject(getNodePath());
        ApiNode parent = (context.getParent() != null) ? context.getParent().getNode() : null;
        out.writeObject(parent);

        boolean expanded = context.isExpanded();
        out.writeBoolean(expanded);
        if (expanded && parent == null) { // rebuild tree (using node paths)
            writeTree(this, out);
        }

        // serialize uncommitted changes
        boolean hasChanges = context.hasChanges() || displayNameChanged;
        out.writeBoolean(hasChanges);
        if (hasChanges && parent == null) // ensures we only do this once since the changes are for the entire tree
        {
            List<ApiNodeChange> changes = new ArrayList<ApiNodeChange>();
            for (NodeChange<NodeContext<ApiNode>> change : context.getChanges()) {
                if (change instanceof NodeChange.Created) {
                    NodeChange.Created<NodeContext<ApiNode>> created = (NodeChange.Created<NodeContext<ApiNode>>) change;
                    changes.add(new ApiNodeChange.Created(created));
                } else if (change instanceof NodeChange.Destroyed) {
                    NodeChange.Destroyed<NodeContext<ApiNode>> destroyed = (NodeChange.Destroyed<NodeContext<ApiNode>>) change;
                    changes.add(new ApiNodeChange.Destroyed(destroyed));
                } else if (change instanceof NodeChange.Moved) {
                    NodeChange.Moved<NodeContext<ApiNode>> moved = (NodeChange.Moved<NodeContext<ApiNode>>) change;
                    changes.add(new ApiNodeChange.Moved(moved));
                } else if (change instanceof NodeChange.Renamed) {
                    NodeChange.Renamed<NodeContext<ApiNode>> renamed = (NodeChange.Renamed<NodeContext<ApiNode>>) change;
                    changes.add(new ApiNodeChange.Renamed(renamed));
                } else if (change instanceof NodeChange.Updated) {
                    NodeChange.Updated<NodeContext<ApiNode>> updated = (NodeChange.Updated<NodeContext<ApiNode>>) change;
                    changes.add(new ApiNodeChange.Updated(updated));
                } else {
                    throw new IOException("Cannot serialize: Non-compatible node change object " + change);
                }
            }
            out.writeObject(changes);
        }
    }

    private void readTree(MultiPathNodeVisitor visitor, ObjectInputStream in) throws IOException, ClassNotFoundException {
        NodePath path = (NodePath) in.readObject();
        visitor.add(path);
        int count = in.readInt();
        for (int i = 0; i < count; i++) {
            readTree(visitor, in);
        }
    }

    private void writeTree(Node node, ObjectOutputStream out) throws IOException {
        out.writeObject(node.getNodePath());
        if (node.isChildrenLoaded()) {
            out.writeInt(node.getChildCount());
            for (Node child : node) {
                writeTree(child, out);
            }
        } else {
            out.writeInt(0);
        }
    }

    private void checkNonRoot() {
        if (isRoot()) {
            throw new UnsupportedOperationException("Operation not supported on root node");
        }
    }

    private void checkChildrenLoaded() {
        if (!context.isExpanded()) {
            throw new IllegalStateException("Children are not loaded");
        }
    }

    private Builder getStateBuilder() {
        return new NodeState.Builder(context.getState());
    }

    private void setState(Builder builder) {
        context.setState(builder.build());
    }

    private class ApiNodeModelIterator implements Iterator<Node> {
        private Iterator<ApiNode> itr = context.iterator();
        private ApiNode last;

        @Override
        public boolean hasNext() {
            return itr.hasNext();
        }

        @Override
        public Node next() {
            last = itr.next();
            return last;
        }

        @Override
        public void remove() {
            if (last == null) {
                throw new IllegalStateException();
            }

            last.context.remove();
        }
    }
}
