/*
 * 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.jackrabbit.oak.jcr.observation;

import static java.util.Collections.emptyMap;
import static org.apache.jackrabbit.JcrConstants.JCR_MIXINTYPES;
import static org.apache.jackrabbit.JcrConstants.JCR_PRIMARYTYPE;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;

import javax.jcr.Value;
import javax.jcr.observation.Event;

import org.apache.jackrabbit.api.observation.JackrabbitEvent;
import org.apache.jackrabbit.oak.api.Blob;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.api.blob.BlobAccessProvider;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.commons.collections.IterableUtils;
import org.apache.jackrabbit.oak.namepath.NamePathMapper;
import org.apache.jackrabbit.oak.plugins.value.jcr.PartialValueFactory;
import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
import org.jetbrains.annotations.NotNull;

/**
 * Event factory for generating JCR event instances that are optimized
 * for minimum memory overhead. Each factory instance keeps track of the
 * event information (like the user identifier and the commit timestamp)
 * shared across all events from a single commit. The generated events
 * instances postpone things like path mappings and the construction of
 * the event info maps to as late as possible to avoid the memory overhead
 * of keeping track of pre-computed values.
 */
public class EventFactory {
    public static final String USER_DATA = "user-data";
    
    public static final String BEFOREVALUE = "beforeValue";
    public static final String AFTERVALUE = "afterValue";

    private final NamePathMapper mapper;

    private final PartialValueFactory valueFactory;

    private final String userID;

    private final String userData;

    private final long date;

    private final boolean external;

    EventFactory(@NotNull NamePathMapper mapper,
            @NotNull BlobAccessProvider blobAccessProvider,
            @NotNull CommitInfo commitInfo) {
        this.mapper = mapper;
        this.valueFactory = new PartialValueFactory(mapper, blobAccessProvider);
        if (!commitInfo.isExternal()) {
            this.userID = commitInfo.getUserId();
            Object userData = commitInfo.getInfo().get(USER_DATA);
            this.userData = userData instanceof String ? (String) userData : null;
            this.date = commitInfo.getDate();
            this.external = false;
        } else {
            this.userID = commitInfo.getUserId();
            this.userData = null;
            this.date = commitInfo.getDate(); // rough estimate
            this.external = true;
        }
    }

    Event propertyAdded(
            final PropertyState after, final String primaryType, final Iterable<String> mixinTypes,
            String path, String name, String identifier) {
        return new EventImpl(path, name, identifier) {
            @Override
            public int getType() {
                return PROPERTY_ADDED;
            }
            @Override
            public Map<?, ?> getInfo() {
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(AFTERVALUE, createValue(after));
                return Collections.unmodifiableMap(builder);
            }
            @Override
            public Map<?, ?> getInfoToString() {
                if (!after.getType().equals(Type.BINARY)) {
                    return getInfo();
                }
                // for binary values just the binary information is logged
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(AFTERVALUE, getSimpleBinaryReference(after));
                return Collections.unmodifiableMap(builder);
            }
        };
    }

    Event propertyChanged(
            final PropertyState before, final PropertyState after,
            final String primaryType, final Iterable<String> mixinTypes,
            String path, String name, String identifier) {
        return new EventImpl(path, name, identifier) {
            @Override
            public int getType() {
                return PROPERTY_CHANGED;
            }
            @Override
            public Map<?, ?> getInfo() {
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(BEFOREVALUE, createValue(before));
                builder.put(AFTERVALUE, createValue(after));
                return Collections.unmodifiableMap(builder);
            }
            @Override
            public Map<?, ?> getInfoToString() {
                if (!before.getType().equals(Type.BINARY)) {
                    return getInfo();
                }
                // for binary values just the binary information is logged
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(BEFOREVALUE, getSimpleBinaryReference(before));
                builder.put(AFTERVALUE, getSimpleBinaryReference(after));
                return Collections.unmodifiableMap(builder);
            }
        };
    }

    Event propertyDeleted(
            final PropertyState before, final String primaryType, final Iterable<String> mixinTypes,
            String path, String name, String identifier) {
        return new EventImpl(path, name, identifier) {
            @Override
            public int getType() {
                return PROPERTY_REMOVED;
            }
            @Override
            public Map<?, ?> getInfo() {
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(BEFOREVALUE, createValue(before));
                return Collections.unmodifiableMap(builder);
            }
            @Override
            public Map<?, ?> getInfoToString() {
                if (!before.getType().equals(Type.BINARY)) {
                    return getInfo();
                }
                // for binary values just the binary information is logged
                Map<Object, Object> builder = new HashMap<>();
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                builder.put(BEFOREVALUE, getSimpleBinaryReference(before));
                return Collections.unmodifiableMap(builder);
            }
        };
    }

    private Object createValue(PropertyState property) {
        if (property.isArray()) {
            List<Value> values = valueFactory.createValues(property);
            return values.toArray(new Value[values.size()]);
        } else {
            return valueFactory.createValue(property);
        }
    }

    Event nodeAdded(final String primaryType, final Iterable<String> mixinTypes,
            String path, String name, String identifier) {
        return new EventImpl(path, name, identifier) {
            @Override
            public int getType() {
                return NODE_ADDED;
            }
            @Override
            public Map<?, ?> getInfo() {
                return createInfoMap(primaryType, mixinTypes);
            }
        };
    }

    Event nodeDeleted(final String primaryType, final Iterable<String> mixinTypes,
            String path, String name, String identifier) {
        return new EventImpl(path, name, identifier) {
            @Override
            public int getType() {
                return NODE_REMOVED;
            }
            @Override
            public Map<?, ?> getInfo() {
                return createInfoMap(primaryType, mixinTypes);
            }
        };
    }

    Event nodeMoved(
            final String primaryType, final Iterable<String> mixinTypes,
            String parent, String name, String identifier,
            final String sourcePath) {
        return new EventImpl(parent, name, identifier) {
            @Override
            public int getType() {
                return NODE_MOVED;
            }
            @Override
            public Map<?, ?> getInfo() {
                Map<Object, Object> builder = new HashMap<>();
                builder.put("srcAbsPath", mapper.getJcrPath(sourcePath));
                builder.put("destAbsPath", getPath());
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                return Collections.unmodifiableMap(builder);
            }
        };
    }

    Event nodeReordered(
            final String primaryType, final Iterable<String> mixinTypes,
            String parent, String name, String identifier,
            final String destName) {
        return new EventImpl(parent, name, identifier) {
            @Override
            public int getType() {
                return NODE_MOVED;
            }
            @Override
            public Map<?, ?> getInfo() {
                Map<Object, Object> builder = new HashMap<>();
                builder.put("srcChildRelPath", mapper.getJcrName(name));
                builder.put("destChildRelPath", mapper.getJcrName(destName));
                builder.putAll(createInfoMap(primaryType, mixinTypes));
                return Collections.unmodifiableMap(builder);
            }
        };
    }

    private Map<String, ?> createInfoMap(String primaryType, Iterable<String> mixinTypes) {
        if (IterableUtils.isEmpty(mixinTypes)) {
            return Map.of(
                    JCR_PRIMARYTYPE, mapper.getJcrName(primaryType));
        } else {
            List<String> jcrNames = new ArrayList<>();
            for (String name : mixinTypes) {
                jcrNames.add(mapper.getJcrName(name));
            }
            return Map.of(
                    JCR_PRIMARYTYPE, mapper.getJcrName(primaryType),
                    JCR_MIXINTYPES, IterableUtils.toArray(jcrNames, String.class));
        }
    }

    //---------------------------------------------------------< EventImpl >--

    private abstract class EventImpl implements JackrabbitEvent {

        /**
         * Path of the parent node of the item this event is about.
         */
        private final String parent;

        /**
         * Name of the item this event is about.
         */
        protected final String name;

        private final String identifier;

        EventImpl(String parent, String name, String identifier) {
            this.parent = parent;
            this.name = name;
            this.identifier = identifier;
        }

        //---------------------------------------------------------< Event >--

        @Override
        public String getPath() {
            return PathUtils.concat(
                    mapper.getJcrPath(parent), mapper.getJcrName(name));
        }

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

        @Override
        public Map<?, ?> getInfo() {
            return emptyMap();
        }

        @Override
        public String getUserID() {
            return userID;
        }

        @Override
        public String getUserData() {
            return userData;
        }

        @Override
        public long getDate() {
            return date;
        }

        //-----------------------------------------------< JackrabbitEvent >--

        @Override
        public boolean isExternal() {
            return external;
        }

        //--------------------------------------------------------< Object >--

        /** A custom version of getInfo(), which is only used
         * for the toString() implementation; the goal is to limit the amount
         * of data that is returned; no binary data should be logged.
         * This is not included in hash calculation and equality checks.
         * 
         * @return a serialized version of getInfo
         */
        public Map<?, ?> getInfoToString() {
            return getInfo();
        }

        @Override
        public boolean equals(Object object) {
            if (this == object) {
                return true;
            } else if (object instanceof EventImpl) {
                EventImpl that = (EventImpl) object;
                return getType() == that.getType()
                        && getPath().equals(that.getPath())
                        && getIdentifier().equals(that.getIdentifier())
                        && getInfo().equals(that.getInfo())
                        && Objects.equals(getUserID(), that.getUserID())
                        && Objects.equals(getUserData(), that.getUserData())
                        && getDate() == that.getDate()
                        && isExternal() == that.isExternal();
            } else {
                return false;
            }
        }

        @Override
        public int hashCode() {
            return Objects.hash(
                    getType(), getPath(), getIdentifier(), getInfo(),
                    getUserID(), getUserData(), getDate(), isExternal());
        }

        @Override
        public String toString() {
            return new StringJoiner(", ", "Event [", "]")
                    .add("type=" + getType())
                    .add("path=" + getPath())
                    .add("identifier=" + getIdentifier())
                    .add("info=" + getInfoToString())
                    .add("userID=" + getUserID())
                    .add("userData=" + getUserData())
                    .add("date=" + getDate())
                    .add("external=" + isExternal())
                    .toString();
        }

        /**
         * Return a brief string which references a binary.
         * @param property a binary property
         * @return a string representation 
         */
        protected @NotNull String getSimpleBinaryReference(@NotNull PropertyState property) {
            // property must be a binary property
            if (property.getType().equals(Type.BINARY)) {
                Blob b = property.getValue(Type.BINARY);
                return String.format("Binary (reference=%s, length=%s bytes)", b.getReference(), b.length());
            }
            return "Not a binary, but " + property.getClass(); // should never happen
        }
    }

}
