/*******************************************************************************
 * This file is part of Zandy.
 *
 * Zandy is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Zandy 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Zandy.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/
package com.gimranov.zandy.app.data;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Objects;

import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteStatement;
import android.util.Log;

import com.gimranov.zandy.app.task.APIRequest;
import org.jetbrains.annotations.NotNull;

/**
 * Represents a Zotero collection of Item objects. Collections can
 * be iterated over, but it's quite likely that iteration will prove unstable
 * if you make changes to them while iterating.
 * <p>
 * To put items in collections, add them using the add(..) methods, and save the
 * collection.
 *
 * @author ajlyon
 */
public class ItemCollection extends HashSet<Item> {
    /**
     * What is this for?
     */
    private static final long serialVersionUID = -4673800475017605707L;

    public static final String TAG = ItemCollection.class.getSimpleName();

    private String id;
    private String title;
    private String key;
    private String etag;

    /**
     * Subcollections of this collection. This is accessed through
     * a lazy getter, which caches the value. This may at times be
     * incorrect, since we don't repopulate this to reflect changes
     * after first populating it.
     */
    private ArrayList<ItemCollection> subcollections;

    /**
     * This is an approximate size that we have from the database-- it may be outdated
     * at times, but it's often better than wading through the database and figuring out
     * the size that way.
     * <p>
     * This size is updated only when loading children;
     */
    private int size;

    private ItemCollection parent;
    private String parentKey;

    public String dbId;

    public String dirty;

    /**
     * Timestamp of last update from server; this is an Atom-formatted
     * timestamp
     */
    private String timestamp;

    public ItemCollection(String title) {
        setTitle(title);
        dirty = APIRequest.API_DIRTY;
    }

    public ItemCollection() {
    }

    /**
     * We call void remove(Item) to allow for queueing
     * the action for application on the server, via the API.
     * <p>
     * When fromAPI is not true, queues a collection membership
     * request for the server as well.
     *
     * @param item
     * @param fromAPI False for collection memberships we receive from the server
     * @param db
     */
    public void remove(Item item, boolean fromAPI, Database db) {
        String[] args = {dbId, item.dbId};
        db.rawQuery("delete from itemtocollections where collection_id=? and item_id=?", args);

        if (!fromAPI) {
            APIRequest req = APIRequest.remove(item, this);
            req.status = APIRequest.REQ_NEW;
            req.save(db);
        }
        super.remove(item);
    }

    /* Getters and setters */
    public String getId() {
        return id;
    }

    public String getEtag() {
        return etag;
    }

    public void setEtag(String etag) {
        this.etag = etag;
    }

    public String getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(String timestamp) {
        this.timestamp = timestamp;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        if (!title.equals(this.title)) {
            this.title = title;
            this.dirty = APIRequest.API_DIRTY;
        }
    }

    /* I'm not sure how easy this is to propagate to the API */
    public ItemCollection getParent(Database db) {
        if (parent != null) return parent;
        if (Objects.equals(parentKey, "false")) return null;
        if (parentKey != null) {
            parent = load(parentKey, db);
        }
        return parent;
    }

    public void setParent(ItemCollection parent) {
        this.parentKey = parent.getKey();
        this.parent = parent;
    }

    public void setParent(String parentKey) {
        this.parentKey = parentKey;
    }

    /* These can't be propagated, so it only makes sense before the collection
     * has been saved to the API. */
    public void setId(String id) {
        this.id = id;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public String getKey() {
        return key;
    }

    /**
     * Returns the size, not as measured, but as listed in DB
     *
     * @return
     */
    public int getSize() {
        return size;
    }

    /**
     * Marks the collection as clean and clears the pending additions and
     * removals.
     * <p>
     * Note that dirty markings don't matter until saved to the DB, so
     * this should be followed by a save.
     */
    public void markClean() {
        dirty = APIRequest.API_CLEAN;
    }

    public boolean add(Item item, Database db) {
        return add(item, false, db);
    }

    /**
     * Adds the specified item to this collection.
     * <p>
     * When fromAPI is not true, queues a collection membership
     * request for the server as well.
     *
     * @param item
     * @param fromAPI False for collection memberships we receive from the server
     * @param db
     * @return Whether this is a new item for the collection
     */
    public boolean add(Item item, boolean fromAPI, Database db) {
        for (Item i : this) {
            if (i.equals(item)) {
                Log.d(TAG, "Item already in collection");
                return false;
            }
        }

        super.add(item);
        Log.d(TAG, "Item added to collection");
        if (!fromAPI) {
            Log.d(TAG, "Saving new collection membership request to database");
            APIRequest req = APIRequest.add(item, this);
            req.status = APIRequest.REQ_NEW;
            req.save(db);
        }
        return true;
    }

    /**
     * Returns ArrayList of Item objects not in the specified ArrayList of item keys. Used for determining
     * when items have been deleted from a collection.
     */
    public ArrayList<Item> notInKeys(ArrayList<String> keys) {
        ArrayList<Item> notThere = new ArrayList<Item>();

        for (Item i : this) {
            if (!keys.contains(i.getKey()))
                notThere.add(i);
        }

        return notThere;
    }

    /**
     * Saves the collection metadata to the database.
     * <p>
     * Does nothing with the collection children.
     */
    public void save(Database db) {
        ItemCollection existing = load(key, db);
        if (existing == null) {
            try {
                SQLiteStatement insert = db.compileStatement("insert or replace into collections " +
                        "(collection_name, collection_key, collection_parent, etag, dirty, collection_size, timestamp)" +
                        " values (?, ?, ?, ?, ?, ?, ?)");
                // Why, oh why does bind* use 1-based indexing? And cur.get* uses 0-based!
                insert.bindString(1, title);
                if (key == null) insert.bindNull(2);
                else insert.bindString(2, key);
                if (parentKey == null) insert.bindNull(3);
                else insert.bindString(3, parentKey);
                if (etag == null) insert.bindNull(4);
                else insert.bindString(4, etag);
                if (dirty == null) insert.bindNull(5);
                else insert.bindString(5, dirty);
                insert.bindLong(6, size);
                if (timestamp == null) insert.bindNull(7);
                else insert.bindString(7, timestamp);
                insert.executeInsert();
                insert.clearBindings();
                insert.close();
                Log.d(TAG, "Saved collection with key: " + key);
            } catch (SQLiteException e) {
                Log.e(TAG, "Exception compiling or running insert statement", e);
                throw e;
            }
            // XXX we need a way to handle locally-created collections
            ItemCollection loaded = load(key, db);
            if (loaded == null) {
                Log.e(TAG, "Item didn't stick-- still nothing for key: " + key);
            } else {
                dbId = loaded.dbId;
            }
        } else {
            dbId = existing.dbId;
            try {
                SQLiteStatement update = db.compileStatement("update collections set " +
                        "collection_name=?, etag=?, dirty=?, collection_size=?, timestamp=?" +
                        " where _id=?");
                update.bindString(1, title);
                if (etag == null) update.bindNull(2);
                else update.bindString(2, etag);
                if (dirty == null) update.bindNull(3);
                else update.bindString(3, dirty);
                update.bindLong(4, size);
                if (timestamp == null) update.bindNull(5);
                else update.bindString(5, timestamp);
                update.bindString(6, dbId);
                update.executeInsert();
                update.clearBindings();
                update.close();
                Log.i(TAG, "Updating existing collection.");
            } catch (SQLiteException e) {
                Log.e(TAG, "Exception compiling or running update statement", e);
            }
        }
        db.close();
    }

    /**
     * Saves the item-collection relationship. This saves the collection
     * itself as well.
     *
     * @throws Exception If we can't save the collection or children
     */
    public void saveChildren(Database db) {
        /* The size is about to be the size of the internal ArrayList, so
         * set it now so it'll be propagated to the database if the collection
         * is new.
         *
         * Save it now-- to fix the size, and to make sure we have a database ID.
         */

        loadChildren(db);

        Log.d(TAG, "Collection has dbid: " + dbId);

        /* The saving is implemented by removing all the records for this collection
         * and saving them anew. This is a risky way to do things. One approach is to
         * wrap the operation in a transaction, or we could try to keep track of changes.
         */

        HashSet<String> keys = new HashSet<>();
        for (Item i : this) {
            if (i.dbId == null) i.save(db);
            keys.add(i.dbId);
        }

        db.beginTransaction();
        try {
            String[] cid = {this.dbId};
            db.rawQuery("delete from itemtocollections where collection_id=?", cid);
            for (String i : keys) {
                String[] args = {this.dbId, i};
                db.rawQuery(
                        "insert into itemtocollections (collection_id, item_id) values (?, ?)", args);
            }
            db.setTransactionSuccessful();
        } catch (Exception e) {
            Log.e(TAG, "Exception caught on saving collection children", e);
        } finally {
            db.endTransaction();
        }

        // We can now get a proper and total count
        String[] args = {this.dbId};
        Cursor cur = db.rawQuery(
                "select count(distinct item_id) from itemtocollections where collection_id=?", args);
        cur.moveToFirst();
        if (!cur.isAfterLast()) this.size = cur.getInt(0);
        cur.close();

        save(db);
    }

    /**
     * Loads the Item members of the collection into the ArrayList<>
     */
    public void loadChildren(Database db) {
        if (dbId == null) save(db);
        Log.d(TAG, "Looking for the kids of a collection with id: " + dbId);

        String[] args = {dbId};
        Cursor cursor = db.rawQuery("SELECT item_title, item_type, item_content, etag, dirty, items._id, item_key, item_year, item_creator, items.timestamp, item_children" +
                        " FROM items, itemtocollections WHERE items._id = item_id AND collection_id=? ORDER BY item_title",
                args);
        if (cursor != null) {
            cursor.moveToFirst();
            while (!cursor.isAfterLast()) {
                Item i = Item.load(cursor);
                Log.d(TAG, "Adding an item to the collection: " + (i != null ? i.getTitle() : null));
                size = this.size();
                super.add(i);
                cursor.moveToNext();
            }
            cursor.close();
        } else {
            Log.d(TAG, "Cursor was null, so we still didn't get kids for the collection!");
        }
    }

    /**
     * Gets the subcollections of the current collection.
     * <p>
     * This is a lazy getter and won't check again after the first time.
     *
     * @return
     */
    public ArrayList<ItemCollection> getSubcollections(Database db) {
        if (this.subcollections != null) return this.subcollections;

        this.subcollections = new ArrayList<ItemCollection>();

        String[] args = {this.key};
        Cursor cur = db.query("collections", Database.COLLCOLS, "collection_parent=?", args, null, null, null, null);

        if (cur == null) {
            Log.d(TAG, "No subcollections found for collection: " + this.title);
            return this.subcollections;
        }

        do {
            ItemCollection collection = load(cur);
            if (collection == null) {
                Log.e(TAG, "Got a null collection when loading from cursor, in getSubcollections");
                continue;
            }
            Log.d(TAG, "Found subcollection: " + collection.title);
            this.subcollections.add(collection);
        } while (cur.moveToNext());

        cur.close();
        return this.subcollections;
    }

    /**
     * Loads and returns an ItemCollection for the specified collection key
     * Returns null when no match is found for the specified collKey
     *
     * @param collKey
     * @return
     */
    public static ItemCollection load(String collKey, Database db) {
        if (collKey == null) return null;
        String[] cols = Database.COLLCOLS;
        String[] args = {collKey};
        Log.i(TAG, "Loading collection with key: " + collKey);
        Cursor cur = db.query("collections", cols, "collection_key=?", args, null, null, null, null);

        ItemCollection coll = load(cur);
        if (coll == null) Log.i(TAG, "Null collection loaded!");
        if (cur != null) cur.close();
        return coll;
    }

    /**
     * Loads a collection from the specified Cursor, where the cursor was created using
     * the recommended query in Database.COLLCOLS
     * <p>
     * Returns null when the specified cursor is null.
     * <p>
     * Does not close the cursor!
     *
     * @param cur
     * @return An ItemCollection object for the current row of the Cursor
     */
    public static ItemCollection load(Cursor cur) {
        ItemCollection coll = new ItemCollection();
        if (cur == null) {
            return null;
        }

        coll.setTitle(cur.getString(0));
        coll.setParent(cur.getString(1));
        coll.etag = cur.getString(2);
        coll.dirty = cur.getString(3);
        coll.dbId = cur.getString(4);
        coll.setKey(cur.getString(5));
        coll.size = cur.getInt(6);
        coll.timestamp = cur.getString(7);
        return coll;
    }

    /**
     * Gives us ItemCollection objects to feed into something like UI
     */
    public static ArrayList<ItemCollection> getCollections(Database db) {
        ArrayList<ItemCollection> collections = new ArrayList<ItemCollection>();
        ItemCollection coll;
        String[] cols = Database.COLLCOLS;

        Cursor cur = db.query("collections", cols, "", null, null, null, "collection_name", null);

        if (cur == null) {
            Log.d(TAG, "No collections found in database");
            return collections;
        }

        return getItemCollections(collections, cur);
    }

    /**
     * Gives us ItemCollection objects containing given item
     * to feed into something like UI
     */
    public static ArrayList<ItemCollection> getCollections(Item i, Database db) {
        ArrayList<ItemCollection> collections = new ArrayList<ItemCollection>();
        String[] args = {i.dbId};
        Cursor cursor = db.rawQuery("SELECT collection_name, collection_parent," +
                        " etag, dirty, collections._id, collection_key, collection_size," +
                        " timestamp FROM collections, itemtocollections" +
                        " WHERE collections._id = collection_id AND item_id=?" +
                        "	 ORDER BY collection_name",
                args);

        if (cursor == null) {
            Log.d(TAG, "No collections found for item");
            return collections;
        }

        return getItemCollections(collections, cursor);
    }

    @NotNull
    private static ArrayList<ItemCollection> getItemCollections(ArrayList<ItemCollection> collections, Cursor cursor) {
        ItemCollection coll;
        do {
            Log.d(TAG, "Adding collection to collection list");
            coll = load(cursor);
            collections.add(coll);
        } while (cursor.moveToNext());
        cursor.close();

        return collections;
    }

    /**
     * Gives us count of ItemCollection objects containing given item
     * to feed into something like UI
     */
    static int getCollectionCount(Item i, Database db) {
        String[] args = {i.dbId};
        Cursor cursor = db.rawQuery("SELECT COUNT(*) " +
                        " FROM collections, itemtocollections" +
                        " WHERE collections._id = collection_id AND item_id=?",
                args);

        if (cursor == null) {
            Log.d(TAG, "No collections found for item");
            return 0;
        }

        int count = cursor.getInt(0);
        cursor.close();
        return count;
    }
}
