/*
 * Copyright 2010-2014 Ning, Inc.
 * Copyright 2014-2016 Groupon, Inc
 * Copyright 2014-2016 The Billing Project, LLC
 *
 * The Billing Project 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.killbill.billing.invoice.tree;

import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;

import org.joda.time.LocalDate;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.invoice.tree.Item.ItemAction;

import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;

/**
 * Keeps track of all the items existing on a specified interval.
 */
public class ItemsInterval {

    private final UUID targetInvoiceId;
    private final ItemsNodeInterval interval;
    private LinkedList<Item> items;

    public ItemsInterval(final ItemsNodeInterval interval, final UUID targetInvoiceId) {
        this(interval, targetInvoiceId, null);
    }

    public ItemsInterval(final ItemsNodeInterval interval, final UUID targetInvoiceId, final Item initialItem) {
        this.interval = interval;
        this.targetInvoiceId = targetInvoiceId;
        this.items = Lists.newLinkedList();
        if (initialItem != null) {
            items.add(initialItem);
        }
    }

    public Item findItem(final UUID targetId) {
        return Iterables.tryFind(items, new Predicate<Item>() {
            @Override
            public boolean apply(final Item input) {
                return input.getId().equals(targetId);
            }
        }).orNull();
    }

    public List<Item> getItems() {
        return items;
    }

    public void buildForMissingInterval(final LocalDate startDate, final LocalDate endDate, final List<Item> output, final boolean addRepair) {
        final Item item = createNewItem(startDate, endDate, addRepair);
        if (item != null) {
            output.add(item);
        }
    }

    /**
     * Determines what is left based on the mergeMode and the action for each item.
     *
     * @param output
     * @param mergeMode
     * @return whether or not the parent should ignore the interval covered by the child interval
     */
    public void buildFromItems(final List<Item> output, final boolean mergeMode) {
        final Item item = getResultingItem(mergeMode);
        if (item != null) {
            output.add(item);
        }
    }

    /**
     * Remove all the cancelling pairs (ADD/CANCEL) for which CANCEL linkedId points to ADD id.
     *
     * @return true if there is no more items
     */
    public boolean mergeCancellingPairs() {
        final Multimap<UUID, Item> cancellingPairPerInvoiceItemId = LinkedListMultimap.<UUID, Item>create();
        for (final Item item : items) {
            final UUID invoiceItemId = (item.getAction() == ItemAction.ADD) ? item.getId() : item.getLinkedId();
            cancellingPairPerInvoiceItemId.put(invoiceItemId, item);
        }

        for (final UUID invoiceItemId : cancellingPairPerInvoiceItemId.keySet()) {
            final Collection<Item> itemsToRemove = cancellingPairPerInvoiceItemId.get(invoiceItemId);
            Preconditions.checkState(itemsToRemove.size() <= 2, "Too many repairs for invoiceItemId='%s': %s", invoiceItemId, itemsToRemove);
            if (itemsToRemove.size() == 2) {
                for (final Item itemToRemove : itemsToRemove) {
                    items.remove(itemToRemove);
                }
            }
        }

        return items.isEmpty();
    }

    public Iterable<Item> get_ADD_items() {
        return Iterables.filter(items, new Predicate<Item>() {
            @Override
            public boolean apply(final Item input) {
                return input.getAction() == ItemAction.ADD;
            }
        });
    }

    public Iterable<Item> get_CANCEL_items() {
        return Iterables.filter(items, new Predicate<Item>() {
            @Override
            public boolean apply(final Item input) {
                return input.getAction() == ItemAction.CANCEL;
            }
        });
    }

    public NodeInterval getNodeInterval() {
        return interval;
    }

    private Item getResultingItem(final boolean mergeMode) {
        return mergeMode ? getResulting_CANCEL_Item() : getResulting_ADD_Item();
    }

    private Item getResulting_CANCEL_Item() {
        Preconditions.checkState(items.size() == 0 || items.size() == 1);
        return getResulting_CANCEL_ItemNoChecks();
    }

    private Item getResulting_CANCEL_ItemNoChecks() {
        return Iterables.tryFind(items, new Predicate<Item>() {
            @Override
            public boolean apply(final Item input) {
                return input.getAction() == ItemAction.CANCEL;
            }
        }).orNull();
    }

    private Item getResulting_ADD_Item() {

        //
        // At this point we pruned the items so that we can have either:
        // - 2 items (ADD + CANCEL, where CANCEL does NOT point to ADD item-- otherwise this is a cancelling pair that
        //            would have been removed in mergeCancellingPairs logic)
        // - 1 ADD item, simple enough we return it
        // - 1 CANCEL, there is nothing to return but the period will be ignored by the parent
        // - Nothing at all; this valid, this just means its original items got removed during mergeCancellingPairs logic,
        //   but its NodeInterval has children so it could not be deleted.
        //
        Preconditions.checkState(items.size() <= 2, "Double billing detected: %s", items);

        final Item item = items.size() > 0 && items.get(0).getAction() == ItemAction.ADD ? items.get(0) : null;

        if (item != null) {
            final Set<UUID> addItemsCancelled = new HashSet<UUID>();
            if (items.size() > 1) {
                addItemsCancelled.add(items.get(1).getLinkedId());
            }
            final Set<UUID> addItemsToBeCancelled = new HashSet<UUID>();
            checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
        }

        return item;
    }

    private void checkDoubleBilling(final Set<UUID> addItemsCancelled, final Set<UUID> addItemsToBeCancelled) {
        final ItemsNodeInterval parentNodeInterval = (ItemsNodeInterval) interval.getParent();
        if (parentNodeInterval == null) {
            Preconditions.checkState(addItemsCancelled.equals(addItemsToBeCancelled), "Double billing detected: addItemsCancelled=%s, addItemsToBeCancelled=%s", addItemsCancelled, addItemsToBeCancelled);
            return;
        }
        final ItemsInterval parentItemsInterval = parentNodeInterval.getItemsInterval();

        final Item parentAddItem = parentItemsInterval.getResulting_ADD_Item();
        if (parentAddItem != null) {
            addItemsToBeCancelled.add(parentAddItem.getId());
        }

        final Item parentCancelItem = parentItemsInterval.getResulting_CANCEL_ItemNoChecks();
        if (parentCancelItem != null) {
            addItemsCancelled.add(parentCancelItem.getLinkedId());
        }

        parentItemsInterval.checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
    }

    // Just ensure that ADD items precedes CANCEL items
    public void insertSortedItem(final Item item) {
        items.add(item);
        Collections.sort(items, new Comparator<Item>() {
            @Override
            public int compare(final Item o1, final Item o2) {
                if (o1.getAction() == ItemAction.ADD && o2.getAction() == ItemAction.CANCEL) {
                    return -1;
                } else if (o1.getAction() == ItemAction.CANCEL && o2.getAction() == ItemAction.ADD) {
                    return 1;
                } else {
                    return 0;
                }
            }
        });
    }

    public void cancelItems(final Item item) {
        Preconditions.checkState(item.getAction() == ItemAction.ADD);
        Preconditions.checkState(items.size() == 1);
        Preconditions.checkState(items.get(0).getAction() == ItemAction.CANCEL);
        items.clear();
    }

    public void remove(final Item item) {
        items.remove(item);
    }

    public Item getCancellingItemIfExists(final UUID targetId) {
        return Iterables.tryFind(items,
                                 new Predicate<Item>() {
                                     @Override
                                     public boolean apply(final Item input) {
                                         return input.getAction() == ItemAction.CANCEL && input.getLinkedId().equals(targetId);
                                     }
                                 }).orNull();
    }

    public Item getCancelledItemIfExists(final UUID linkedId) {
        return Iterables.tryFind(items,
                                 new Predicate<Item>() {
                                     @Override
                                     public boolean apply(final Item input) {
                                         return input.getAction() == ItemAction.ADD && input.getId().equals(linkedId);
                                     }
                                 }).orNull();
    }

    public int size() {
        return items.size();
    }

    /**
     * Creates a new item.
     * <p/>
     * <ul>
     * <li>In normal mode, we only consider ADD items. This happens when for instance an existing item was partially repaired
     * and there is a need to create a new item which represents the part left -- that was not repaired.
     * <li>In mergeMode, we allow to create new items that are the missing repaired items (CANCEL).
     * </ul>
     *
     * @param startDate start date of the new item to create
     * @param endDate   end date of the new item to create
     * @param mergeMode mode to consider.
     * @return
     */
    private Item createNewItem(final LocalDate startDate, final LocalDate endDate, final boolean mergeMode) {

        final Item item = getResultingItem(mergeMode);
        if (item == null) {
            return null;
        }

        final InvoiceItem proratedInvoiceItem = item.toProratedInvoiceItem(startDate, endDate);
        if (proratedInvoiceItem == null) {
            return null;
        }

        final Item result = new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
        if (item.getAction() == ItemAction.CANCEL) {
            item.incrementCurrentRepairedAmount(result.getAmount());
        }
        return result;
    }

}
