/*
 * Copyright (C) 2010 The Android Open Source Project
 *
 * 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.android.gallery3d.data;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

import com.android.gallery3d.app.GalleryApp;
import com.android.gallery3d.common.LruCache;
import com.android.gallery3d.common.Utils;
import com.android.gallery3d.data.DownloadEntry.Columns;
import com.android.gallery3d.util.Future;
import com.android.gallery3d.util.FutureListener;
import com.android.gallery3d.util.ThreadPool;
import com.android.gallery3d.util.ThreadPool.CancelListener;
import com.android.gallery3d.util.ThreadPool.Job;
import com.android.gallery3d.util.ThreadPool.JobContext;

import java.io.File;
import java.net.URL;
import java.util.HashMap;
import java.util.HashSet;

public class DownloadCache {
    private static final String TAG = "DownloadCache";
    private static final int MAX_DELETE_COUNT = 16;
    private static final int LRU_CAPACITY = 4;

    private static final String TABLE_NAME = DownloadEntry.SCHEMA.getTableName();

    private static final String QUERY_PROJECTION[] = {Columns.ID, Columns.DATA};
    private static final String WHERE_HASH_AND_URL = String.format(
            "%s = ? AND %s = ?", Columns.HASH_CODE, Columns.CONTENT_URL);
    private static final int QUERY_INDEX_ID = 0;
    private static final int QUERY_INDEX_DATA = 1;

    private static final String FREESPACE_PROJECTION[] = {
            Columns.ID, Columns.DATA, Columns.CONTENT_URL, Columns.CONTENT_SIZE};
    private static final String FREESPACE_ORDER_BY =
            String.format("%s ASC", Columns.LAST_ACCESS);
    private static final int FREESPACE_IDNEX_ID = 0;
    private static final int FREESPACE_IDNEX_DATA = 1;
    private static final int FREESPACE_INDEX_CONTENT_URL = 2;
    private static final int FREESPACE_INDEX_CONTENT_SIZE = 3;

    private static final String ID_WHERE = Columns.ID + " = ?";

    private static final String SUM_PROJECTION[] =
            {String.format("sum(%s)", Columns.CONTENT_SIZE)};
    private static final int SUM_INDEX_SUM = 0;

    private final LruCache<String, Entry> mEntryMap =
            new LruCache<String, Entry>(LRU_CAPACITY);
    private final HashMap<String, DownloadTask> mTaskMap =
            new HashMap<String, DownloadTask>();
    private final File mRoot;
    private final GalleryApp mApplication;
    private final SQLiteDatabase mDatabase;
    private final long mCapacity;

    private long mTotalBytes = 0;
    private boolean mInitialized = false;

    public DownloadCache(GalleryApp application, File root, long capacity) {
        mRoot = Utils.checkNotNull(root);
        mApplication = Utils.checkNotNull(application);
        mCapacity = capacity;
        mDatabase = new DatabaseHelper(application.getAndroidContext())
                .getWritableDatabase();
    }

    private Entry findEntryInDatabase(String stringUrl) {
        long hash = Utils.crc64Long(stringUrl);
        String whereArgs[] = {String.valueOf(hash), stringUrl};
        Cursor cursor = mDatabase.query(TABLE_NAME, QUERY_PROJECTION,
                WHERE_HASH_AND_URL, whereArgs, null, null, null);
        try {
            if (cursor.moveToNext()) {
                File file = new File(cursor.getString(QUERY_INDEX_DATA));
                long id = cursor.getInt(QUERY_INDEX_ID);
                Entry entry = null;
                synchronized (mEntryMap) {
                    entry = mEntryMap.get(stringUrl);
                    if (entry == null) {
                        entry = new Entry(id, file);
                        mEntryMap.put(stringUrl, entry);
                    }
                }
                return entry;
            }
        } finally {
            cursor.close();
        }
        return null;
    }

    public Entry download(JobContext jc, URL url) {
        if (!mInitialized) initialize();

        String stringUrl = url.toString();

        // First find in the entry-pool
        synchronized (mEntryMap) {
            Entry entry = mEntryMap.get(stringUrl);
            if (entry != null) {
                updateLastAccess(entry.mId);
                return entry;
            }
        }

        // Then, find it in database
        TaskProxy proxy = new TaskProxy();
        synchronized (mTaskMap) {
            Entry entry = findEntryInDatabase(stringUrl);
            if (entry != null) {
                updateLastAccess(entry.mId);
                return entry;
            }

            // Finally, we need to download the file ....
            // First check if we are downloading it now ...
            DownloadTask task = mTaskMap.get(stringUrl);
            if (task == null) { // if not, start the download task now
                task = new DownloadTask(stringUrl);
                mTaskMap.put(stringUrl, task);
                task.mFuture = mApplication.getThreadPool().submit(task, task);
            }
            task.addProxy(proxy);
        }

        return proxy.get(jc);
    }

    private void updateLastAccess(long id) {
        ContentValues values = new ContentValues();
        values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
        mDatabase.update(TABLE_NAME, values,
                ID_WHERE, new String[] {String.valueOf(id)});
    }

    private synchronized void freeSomeSpaceIfNeed(int maxDeleteFileCount) {
        if (mTotalBytes <= mCapacity) return;
        Cursor cursor = mDatabase.query(TABLE_NAME,
                FREESPACE_PROJECTION, null, null, null, null, FREESPACE_ORDER_BY);
        try {
            while (maxDeleteFileCount > 0
                    && mTotalBytes > mCapacity && cursor.moveToNext()) {
                long id = cursor.getLong(FREESPACE_IDNEX_ID);
                String url = cursor.getString(FREESPACE_INDEX_CONTENT_URL);
                long size = cursor.getLong(FREESPACE_INDEX_CONTENT_SIZE);
                String path = cursor.getString(FREESPACE_IDNEX_DATA);
                boolean containsKey;
                synchronized (mEntryMap) {
                    containsKey = mEntryMap.containsKey(url);
                }
                if (!containsKey) {
                    --maxDeleteFileCount;
                    mTotalBytes -= size;
                    new File(path).delete();
                    mDatabase.delete(TABLE_NAME,
                            ID_WHERE, new String[]{String.valueOf(id)});
                } else {
                    // skip delete, since it is being used
                }
            }
        } finally {
            cursor.close();
        }
    }

    private synchronized long insertEntry(String url, File file) {
        long size = file.length();
        mTotalBytes += size;

        ContentValues values = new ContentValues();
        String hashCode = String.valueOf(Utils.crc64Long(url));
        values.put(Columns.DATA, file.getAbsolutePath());
        values.put(Columns.HASH_CODE, hashCode);
        values.put(Columns.CONTENT_URL, url);
        values.put(Columns.CONTENT_SIZE, size);
        values.put(Columns.LAST_UPDATED, System.currentTimeMillis());
        return mDatabase.insert(TABLE_NAME, "", values);
    }

    private synchronized void initialize() {
        if (mInitialized) return;
        mInitialized = true;
        if (!mRoot.isDirectory()) mRoot.mkdirs();
        if (!mRoot.isDirectory()) {
            throw new RuntimeException("cannot create " + mRoot.getAbsolutePath());
        }

        Cursor cursor = mDatabase.query(
                TABLE_NAME, SUM_PROJECTION, null, null, null, null, null);
        mTotalBytes = 0;
        try {
            if (cursor.moveToNext()) {
                mTotalBytes = cursor.getLong(SUM_INDEX_SUM);
            }
        } finally {
            cursor.close();
        }
        if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
    }

    private final class DatabaseHelper extends SQLiteOpenHelper {
        public static final String DATABASE_NAME = "download.db";
        public static final int DATABASE_VERSION = 2;

        public DatabaseHelper(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            DownloadEntry.SCHEMA.createTables(db);
            // Delete old files
            for (File file : mRoot.listFiles()) {
                if (!file.delete()) {
                    Log.w(TAG, "fail to remove: " + file.getAbsolutePath());
                }
            }
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            //reset everything
            DownloadEntry.SCHEMA.dropTables(db);
            onCreate(db);
        }
    }

    public class Entry {
        public File cacheFile;
        protected long mId;

        Entry(long id, File cacheFile) {
            mId = id;
            this.cacheFile = Utils.checkNotNull(cacheFile);
        }
    }

    private class DownloadTask implements Job<File>, FutureListener<File> {
        private HashSet<TaskProxy> mProxySet = new HashSet<TaskProxy>();
        private Future<File> mFuture;
        private final String mUrl;

        public DownloadTask(String url) {
            mUrl = Utils.checkNotNull(url);
        }

        public void removeProxy(TaskProxy proxy) {
            synchronized (mTaskMap) {
                Utils.assertTrue(mProxySet.remove(proxy));
                if (mProxySet.isEmpty()) {
                    mFuture.cancel();
                    mTaskMap.remove(mUrl);
                }
            }
        }

        // should be used in synchronized block of mDatabase
        public void addProxy(TaskProxy proxy) {
            proxy.mTask = this;
            mProxySet.add(proxy);
        }

        @Override
        public void onFutureDone(Future<File> future) {
            File file = future.get();
            long id = 0;
            if (file != null) { // insert to database
                id = insertEntry(mUrl, file);
            }

            if (future.isCancelled()) {
                Utils.assertTrue(mProxySet.isEmpty());
                return;
            }

            synchronized (mTaskMap) {
                Entry entry = null;
                synchronized (mEntryMap) {
                    if (file != null) {
                        entry = new Entry(id, file);
                        Utils.assertTrue(mEntryMap.put(mUrl, entry) == null);
                    }
                }
                for (TaskProxy proxy : mProxySet) {
                    proxy.setResult(entry);
                }
                mTaskMap.remove(mUrl);
                freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
            }
        }

        @Override
        public File run(JobContext jc) {
            // TODO: utilize etag
            jc.setMode(ThreadPool.MODE_NETWORK);
            File tempFile = null;
            try {
                URL url = new URL(mUrl);
                tempFile = File.createTempFile("cache", ".tmp", mRoot);
                // download from url to tempFile
                jc.setMode(ThreadPool.MODE_NETWORK);
                boolean downloaded = DownloadUtils.requestDownload(jc, url, tempFile);
                jc.setMode(ThreadPool.MODE_NONE);
                if (downloaded) return tempFile;
            } catch (Exception e) {
                Log.e(TAG, String.format("fail to download %s", mUrl), e);
            } finally {
                jc.setMode(ThreadPool.MODE_NONE);
            }
            if (tempFile != null) tempFile.delete();
            return null;
        }
    }

    public static class TaskProxy {
        private DownloadTask mTask;
        private boolean mIsCancelled = false;
        private Entry mEntry;

        synchronized void setResult(Entry entry) {
            if (mIsCancelled) return;
            mEntry = entry;
            notifyAll();
        }

        public synchronized Entry get(JobContext jc) {
            jc.setCancelListener(new CancelListener() {
                @Override
                public void onCancel() {
                    mTask.removeProxy(TaskProxy.this);
                    synchronized (TaskProxy.this) {
                        mIsCancelled = true;
                        TaskProxy.this.notifyAll();
                    }
                }
            });
            while (!mIsCancelled && mEntry == null) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    Log.w(TAG, "ignore interrupt", e);
                }
            }
            jc.setCancelListener(null);
            return mEntry;
        }
    }
}
