/**
 * Copyright 2016-2019 Cloudera, Inc.
 *
 * Licensed 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 com.hortonworks.registries.tag.service;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.hortonworks.registries.common.QueryParam;
import com.hortonworks.registries.storage.Storable;
import com.hortonworks.registries.storage.StorableKey;
import com.hortonworks.registries.storage.StorageManager;
import com.hortonworks.registries.storage.util.StorageUtils;
import com.hortonworks.registries.tag.Tag;
import com.hortonworks.registries.tag.TaggedEntity;
import com.hortonworks.registries.tag.TagStorableMapping;
import org.apache.commons.io.IOUtils;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Catalog db based tag service.
 */
public class CatalogTagService implements TagService {
    private static final String TAG_NAMESPACE = new Tag().getNameSpace();
    private static final String TAG_STORABLE_MAPPING_NAMESPACE = new TagStorableMapping().getNameSpace();
    private final StorageManager dao;

    public CatalogTagService(StorageManager dao) {
        this.dao = dao;
        dao.registerStorables(getStorableClasses());

    }

    public static Collection<Class<? extends Storable>> getStorableClasses() {
        InputStream resourceAsStream = CatalogTagService.class.getClassLoader().getResourceAsStream("tagstorables.props");
        HashSet<Class<? extends Storable>> classes = new HashSet<>();
        try {
            List<String> classNames = IOUtils.readLines(resourceAsStream);
            for (String className : classNames) {
                classes.add((Class<? extends Storable>) Class.forName(className));
            }
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }

        return classes;
    }

    // handle this check at application layer since in-memory storage etc does not contain unique key constraint
    private void validateTag(Tag tag) {
        StorageUtils.ensureUnique(tag, this::listTags, QueryParam.params("name", tag.getName()));
    }

    @Override
    public Tag addTag(Tag tag) {
        if (tag.getId() == null) {
            tag.setId(dao.nextId(TAG_NAMESPACE));
        }
        if (tag.getTimestamp() == null) {
            tag.setTimestamp(System.currentTimeMillis());
        }
        validateTag(tag);
        checkCycles(tag, tag.getTags());
        dao.add(tag);
        addTagsForStorable(getTaggedEntity(tag), tag.getTags());
        return tag;
    }

    private void checkCycles(Tag current, List<Tag> tags) {
        for (Tag tag : tags) {
            if (tag.equals(current) || flatten(getTags(getTaggedEntity(tag))).contains(current)) {
                throw new IllegalArgumentException("Tagging " + current +
                                                           " with " + tag + " would result in a cycle.");
            }
        }
    }


    private TaggedEntity getTaggedEntity(Tag tag) {
        return new TaggedEntity(tag.getNameSpace(), tag.getId());
    }

    private List<Tag> flatten(Tag tag) {
        return flatten(Collections.singletonList(tag));
    }

    private List<Tag> flatten(List<Tag> tags) {
        List<Tag> res = new ArrayList<>();
        for (Tag tag: tags) {
            res.add(tag);
            res.addAll(flatten(tag.getTags()));
        }
        return res;
    }

    @Override
    public Tag addOrUpdateTag(Long tagId, Tag tag) {
        if (tag.getId() == null) {
            tag.setId(tagId);
        }
        if (tag.getTimestamp() == null) {
            tag.setTimestamp(System.currentTimeMillis());
        }
        validateTag(tag);
        List<Tag> existingTags = getTags(getTaggedEntity(tag));
        List<Tag> tagsToBeAdded = getTagsToBeAdded(existingTags, tag.getTags());
        List<Tag> tagsToBeRemoved = getTagsToBeRemoved(existingTags, tag.getTags());
        checkCycles(tag, tagsToBeAdded);
        this.dao.addOrUpdate(tag);
        updateTags(getTaggedEntity(tag), tagsToBeAdded, tagsToBeRemoved);
        return tag;
    }

    @Override
    public Tag getTag(Long tagId) {
        Tag tag = new Tag();
        tag.setId(tagId);
        Tag result = this.dao.get(new StorableKey(TAG_NAMESPACE, tag.getPrimaryKey()));
        if (result != null) {
            result.setTags(getTags(getTaggedEntity(result)));
        }
        return result;
    }

    @Override
    public Tag removeTag(Long tagId) {
        Tag tag = getTag(tagId);
        if (tag != null) {
            if (!getEntities(tagId, false).isEmpty()) {
                throw new TagNotEmptyException("Tag not empty, has child entities.");
            }
            removeTagsFromStorable(getTaggedEntity(tag), tag.getTags());
            dao.<Tag>remove(new StorableKey(TAG_NAMESPACE, tag.getPrimaryKey()));
        }
        return tag;
    }

    @Override
    public Collection<Tag> listTags() {
        return makeTags(this.dao.<Tag>list(TAG_NAMESPACE));
    }

    @Override
    public Collection<Tag> listTags(List<QueryParam> queryParams) {
        return makeTags(dao.<Tag>find(TAG_NAMESPACE, queryParams));
    }

    @Override
    public void addTagsForStorable(TaggedEntity taggedEntity, List<Tag> tags) {
        if (tags != null) {
            for (Tag tag : tags) {
                TagStorableMapping tagStorable = new TagStorableMapping();
                tagStorable.setTagId(tag.getId());
                tagStorable.setStorableNamespace(taggedEntity.getNamespace());
                tagStorable.setStorableId(taggedEntity.getId());
                this.dao.add(tagStorable);
            }
        }
    }

    @Override
    public void addOrUpdateTagsForStorable(TaggedEntity taggedEntity, List<Tag> tags) {
        List<Tag> existingTags = getTags(taggedEntity);
        updateTags(taggedEntity, getTagsToBeAdded(existingTags, tags), getTagsToBeRemoved(existingTags, tags));
    }

    private List<Tag> getTagsToBeRemoved(List<Tag> existing, List<Tag> newList) {
        return Lists.newArrayList(
                Sets.difference(ImmutableSet.copyOf(existing), ImmutableSet.copyOf(newList)));
    }

    private List<Tag> getTagsToBeAdded(List<Tag> existing, List<Tag> newList) {
        return Lists.newArrayList(
                Sets.difference(ImmutableSet.copyOf(newList), ImmutableSet.copyOf(existing)));
    }

    private void updateTags(TaggedEntity taggedEntity, List<Tag> tagsToBeAdded, List<Tag> tagsToBeRemoved) {
        removeTagsFromStorable(taggedEntity, tagsToBeRemoved);
        addTagsForStorable(taggedEntity, tagsToBeAdded);
    }

    @Override
    public void removeTagsFromStorable(TaggedEntity taggedEntity, List<Tag> tags) {
        if (tags != null) {
            for (Tag tag : tags) {
                TagStorableMapping tagStorable = new TagStorableMapping();
                tagStorable.setTagId(tag.getId());
                tagStorable.setStorableId(taggedEntity.getId());
                tagStorable.setStorableNamespace(taggedEntity.getNamespace());
                this.dao.remove(tagStorable.getStorableKey());
            }
        }
    }

    @Override
    public List<Tag> getTags(TaggedEntity taggedEntity) {
        List<Tag> tags = new ArrayList<>();
        QueryParam qp1 = new QueryParam(TagStorableMapping.FIELD_STORABLE_ID,
                                        String.valueOf(taggedEntity.getId()));
        QueryParam qp2 = new QueryParam(TagStorableMapping.FIELD_STORABLE_NAMESPACE,
                                        String.valueOf(taggedEntity.getNamespace()));
        for (TagStorableMapping mapping : listTagStorableMapping(ImmutableList.of(qp1, qp2))) {
            tags.add(getTag(mapping.getTagId()));
        }
        return tags;
    }

    enum State {
        VISITING, VISITED
    }

    @Override
    public List<TaggedEntity> getEntities(Long tagId, boolean recurse) {
        return getEntities(tagId, recurse, new HashMap<Long, State>());
    }

    public List<TaggedEntity> getEntities(Long tagId, boolean recurse, Map<Long, State> state) {
        State tagState = state.get(tagId);
        Set<TaggedEntity> result = new HashSet<>();
        if (tagState == State.VISITING) {
            throw new IllegalStateException("Cycle detected");
        } else if (tagState != State.VISITED) {
            state.put(tagId, State.VISITING);
            for (TaggedEntity taggedEntity : getTaggedEntities(tagId)) {
                if (recurse && Tag.NAMESPACE.equalsIgnoreCase(taggedEntity.getNamespace())) {
                    result.addAll(getEntities(taggedEntity.getId(), recurse, state));
                } else {
                    result.add(taggedEntity);
                }
            }
            state.put(tagId, State.VISITED);
        }
        return new LinkedList<>(result);
    }

    private List<TaggedEntity> getTaggedEntities(Long tagId) {
        List<TaggedEntity> taggedEntities = new ArrayList<>();
        QueryParam qp1 = new QueryParam(TagStorableMapping.FIELD_TAG_ID, String.valueOf(tagId));
        for (TagStorableMapping mapping : listTagStorableMapping(ImmutableList.of(qp1))) {
            taggedEntities.add(new TaggedEntity(mapping.getStorableNamespace(), mapping.getStorableId()));
        }
        return taggedEntities;
    }

    private Collection<TagStorableMapping> listTagStorableMapping(List<QueryParam> params) {
        return dao.find(TAG_STORABLE_MAPPING_NAMESPACE, params);
    }

    private Collection<Tag> makeTags(Collection<Tag> tags) {
        if (tags != null) {
            for (Tag tag : tags) {
                tag.setTags(getTags(getTaggedEntity(tag)));
            }
        }
        return tags;
    }
}