/*
 * Overchan Android (Meta Imageboard Client)
 * Copyright (C) 2014-2016  miku-nyan <https://github.com/miku-nyan>
 *     
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program 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 General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package nya.miku.wishmaster.cache;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.LinkedList;
import java.util.ListIterator;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteStatement;
import android.os.Build;
import android.os.Environment;
import android.provider.BaseColumns;
import nya.miku.wishmaster.common.IOUtils;
import nya.miku.wishmaster.common.Logger;
import nya.miku.wishmaster.ui.CompatibilityImpl;

/**
 * Общий файловый кэш (LRU)
 * @author miku-nyan
 *
 */
public class FileCache {
    private static final String TAG = "FileCache";
    
    public static final String PREFIX_ORIGINALS = "orig_";
    /*package*/ static final String PREFIX_BITMAPS = "thumb_";
    
    /*package*/ static final String PREFIX_PAGES = "page_"; //не удаляются, если в совокупности занимают менее 10%
    /*package*/ static final String PREFIX_DRAFTS = "draft_";
    
    /*package*/ static final String PREFIX_BOARDS = "boards_"; //не удаляются никогда
    
    /** имя файла для состояния вкладок */
    /*package*/ static final String TABS_FILENAME = "tabsstate"; //хранятся в отдельной директории (не кэш)
    
    private static final String NOMEDIA = ".nomedia"; //не удаляется никогда
    
    private static final float PAGES_QUOTA = 0.1f;
    
    private final FileCacheDB database;
    
    private final File filesDirectory;
    
    private final File directory;
    private long maxSize;
    private long maxPagesSize;
    
    private volatile long size;
    private volatile long pagesSize;
    
    private volatile boolean initialized = false;
    private final Object initLock = new Object();
    
    /**
     * Конструктор
     * @param context контекст приложения
     * @param maxSize максимальный размер в байтах (0 - неограниченный)
     */
    public FileCache(Context context, final long maxSize) {
        this.filesDirectory = getAvailableFilesDir(context);
        this.directory = getAvailableCacheDir(context);
        this.database = new FileCacheDB(context);
        transferTabsState(); //legacy
        makeDir();
        makeNomedia();
        
        Thread initThread = new Thread() {
            @Override
            public void run() {
                long[] sizeInDB = database.getSize();
                if (sizeInDB[0] != 0) {
                    FileCache.this.size = sizeInDB[0];
                    FileCache.this.pagesSize = sizeInDB[1];
                } else {
                    resetCache();
                }
                
                setMaxSizeValues(maxSize);
                synchronized (initLock) {
                    initialized = true;
                    initLock.notifyAll();
                    Logger.d(TAG, "File Cache initialized");
                }
            }
        };
        initThread.start();
    }
    
    private void ensureInitialized() {
        if (initialized) return;
        synchronized (initLock) {
            while (!initialized) {
                try {
                    initLock.wait();
                } catch (Exception e) {
                    Logger.e(TAG, e);
                }
            }
        }
    }
    
    /**
     * Установить максимальный размер кэша
     * @param maxSize максимальный размер в байтах (0 - неограниченный)
     */
    public void setMaxSize(long maxSize) {
        ensureInitialized();
        setMaxSizeValues(maxSize);
        trim();
    }
    
    private void setMaxSizeValues(long maxSize) {
        this.maxSize = maxSize;
        this.maxPagesSize = (long) (maxSize * PAGES_QUOTA);
    }
    
    /**
     * Получить текущий размер кэша
     * @return текущий размер в байтах
     */
    public long getCurrentSize() {
        ensureInitialized();
        return size;
    }
    
    /**
     * Получить текущий размер кэша в мегабайтах
     * @return текущий размер в мегабайтах
     */
    public double getCurrentSizeMB() {
        return (double) getCurrentSize() / (1024 * 1024);
    }
    
    /**
     * Очистить кэш (удалить все файлы)
     */
    public void clearCache() {
        ensureInitialized();
        database.put("_clear_cache", -1); //database will reset if app crash
        for (File f : filesOfDir(directory)) {
            if (!isUndeletable(f)) f.delete();
        }
        resetCache();
    }
    
    /**
     * Получить директорию для хранения файлов (не являющуюся кэшем).
     * Данная директория не очищается вместе с кэшем, её размер не контролируется.
     */
    public File getFilesDirectory() {
        return filesDirectory;
    }
    
    /**
     * Получить файл из кэша
     * @param fileName имя файла
     * @return полученный файл, если файл существует, или null, если файл отсутствует
     */
    public File get(String fileName) {
        ensureInitialized();
        synchronized (this) {
            File file = pathToFile(fileName);
            if (file.exists() && !file.isDirectory()) {
                file.setLastModified(System.currentTimeMillis());
                database.touch(fileName);
                return file;
            }
            return null;
        }
    }
    
    /**
     * Создать объект для нового файла (если файл с таким именем уже присутствует в кэше, он удаляется).
     * По окончании действий с файлом (после окончания записи) необходимо вызвать метод {@link #put(File)}, чтобы учесть размер нового файла,
     * или метод {@link #abort(File)}, чтобы отменить создание файла (удалить файл и запись о нём). 
     * Действия при работе с файлами (при необходимости) нужно синхронизировать дополнительно.  
     * @param fileName имя файла
     * @return объект типа {@link File}
     */
    public File create(String fileName) {
        ensureInitialized();
        synchronized (this) {
            makeDir();
            File file = pathToFile(fileName);
            if (file.exists()) {
                delete(file, false);
            }
            database.put(fileName, -1);
            return file;
        }
    }
    
    /**
     * Учитывает размер созданного файла, добавляет к размеру кэша, в случае необходимости удаляются устаревшие файлы. 
     * @param file объект типа {@link File}
     */
    public void put(File file) {
        ensureInitialized();
        synchronized (this) {
            size += file.length();
            if (isPageFile(file)) pagesSize += file.length();
            database.put(file.getName(), file.length());
            trim();
        }
    }
    
    /**
     * Отменить создание файла, удалить файл и запись в базе данных.
     * @param file объект типа {@link File}
     */
    public void abort(File file) {
        ensureInitialized();
        synchronized (this) {
            file.delete();
            database.remove(file.getName());
        }
    }
    
    /**
     * Удалить файл из кэша
     * @param file объект типа {@link File}
     * @return true, если файл удалён успешно, false в противном случае
     */
    public boolean delete(File file) {
        return delete(file, true);
    }
    
    private boolean delete(File file, boolean removeFromDB) {
        ensureInitialized();
        synchronized (this) {
            size -= file.length();
            if (isPageFile(file)) pagesSize -= file.length();
            if (file.delete()) {
                if (removeFromDB) database.remove(file.getName());
                return true;
            } else {
                resetCache();
                return false;
            }
        }
    }
    
    private File pathToFile(String fileName) {
        return new File(directory, fileName);
    }
    
    private void makeDir() {
        if (!directory.exists()) {
            if (!directory.mkdirs()) {
                Logger.e(TAG, "Unable to create file cache dir " + directory.getPath());
            }
        }
    }
    
    private void makeNomedia() {
        try {
            pathToFile(NOMEDIA).createNewFile();
        } catch (Exception e) {
            Logger.e(TAG, "couldn't create .nomedia file", e);
        }
    }
    
    private synchronized void trim() {
        for (int i=0; i<3; ++i) {
            if (maxSize == 0 || size <= maxSize) return;
            
            LinkedList<String> files = database.getFilesForTrim(size - maxSize);
            
            while (size > maxSize) {
                File oldest = null;
                for (ListIterator<String> it = files.listIterator(); it.hasNext();) {
                    File file = pathToFile(it.next());
                    if (isPageFile(file) && pagesSize < maxPagesSize) continue;
                    it.remove();
                    oldest = file;
                    break;
                }
                if (oldest == null) {
                    Logger.e(TAG, "No files to trim");
                    break;
                } else {
                    Logger.d(TAG, "Deleting " + oldest.getPath());
                    if (!delete(oldest)) {
                        Logger.e(TAG, "Cannot delete cache file: " + oldest.getPath());
                        break;
                    }
                }
            }
        }
    }
    
    private synchronized void resetCache() {
        database.resetDB();
        database.insertFiles(filesOfDir(directory));
        long[] sizeInDB = database.getSize();
        size = sizeInDB[0];
        pagesSize = sizeInDB[1];
    }

    private boolean isUndeletable(File file) {
        return isUndeletable(file.getName()); 
    }
    
    private static boolean isUndeletable(String filename) {
        return filename.startsWith(PREFIX_BOARDS) || filename.equals(NOMEDIA);
    }
    
    private static boolean isPageFile(File file) {
        return isPageFile(file.getName());
    }
    
    private static boolean isPageFile(String filename) {
        //the same condition must be in method FileCacheDB.getSize() (SQL) for the sum calculation
        return filename.startsWith(PREFIX_PAGES) || filename.startsWith(PREFIX_DRAFTS);
    }
    
    private File[] filesOfDir(File directory) {
        File[] files = directory.listFiles();
        if (files == null) return new File[0];
        return files;
    }
    
    private static class FileCacheDB {
        private static final int DB_VERSION = 1000;
        private static final String DB_NAME = "filecache.db";
        
        private static final String TABLE_NAME = "files";
        private static final String COL_FILENAME = "name";
        private static final String COL_FILESIZE = "size";
        private static final String COL_TIMESTAMP = "time";
        
        private final DBHelper dbHelper; 
        public FileCacheDB(Context context) {
            dbHelper = new DBHelper(context);
        }
        
        public boolean isExists(String filename) {
            Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, COL_FILENAME + " = ?", new String[] { filename }, null, null, null);
            boolean result = false;
            if (c != null && c.moveToFirst()) result = true;
            if (c != null) c.close();
            return result;
        }
        
        public void touch(String filename) {
            ContentValues cv = new ContentValues();
            cv.put(COL_TIMESTAMP, System.currentTimeMillis());
            dbHelper.getWritableDatabase().update(TABLE_NAME, cv, COL_FILENAME + " = ?", new String[] { filename });
        }
        
        public void put(String filename, long size) {
            ContentValues cv = new ContentValues();
            cv.put(COL_FILENAME, filename);
            cv.put(COL_FILESIZE, size);
            cv.put(COL_TIMESTAMP, System.currentTimeMillis());
            
            if (isExists(filename)) {
                dbHelper.getWritableDatabase().update(TABLE_NAME, cv, COL_FILENAME + " = ?", new String[] { filename });
            } else {
                dbHelper.getWritableDatabase().insert(TABLE_NAME, null, cv);
            }
        }
        
        public void insertFiles(File[] files) {
            SQLiteDatabase database = dbHelper.getWritableDatabase();
            SQLiteStatement statement = database.compileStatement("INSERT INTO " + TABLE_NAME +
                    " (" + COL_FILENAME + ", " + COL_FILESIZE + ", " + COL_TIMESTAMP + ") VALUES (?, ?, ?)");
            database.beginTransaction();
            try {
                for (File file : files) {
                    statement.bindString(1, file.getName());
                    statement.bindLong(2, file.length());
                    statement.bindLong(3, file.lastModified());
                    statement.executeInsert();
                }
                database.setTransactionSuccessful();
            } finally {
                database.endTransaction();
            }
        }
        
        public LinkedList<String> getFilesForTrim(long size) {
            LinkedList<String> list = new LinkedList<>();
            Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, COL_TIMESTAMP, "1000");
            if (c != null) {
                if (c.moveToFirst()) {
                    int nameIndex = c.getColumnIndex(COL_FILENAME);
                    int sizeIndex = c.getColumnIndex(COL_FILESIZE);
                    long tSize = 0;
                    do {
                        String filename = c.getString(nameIndex);
                        if (!isUndeletable(filename)) {
                            list.add(filename);
                            if (!isPageFile(filename)) tSize += c.getLong(sizeIndex);
                            if (tSize >= size) break;
                        }
                    } while (c.moveToNext());
                }
                c.close();
            }
            return list;
        }
        
        public long[] getSize() {
            try {
                return getSizeInternal();
            } catch (SQLiteException e) {
                if (e.getMessage() != null && e.getMessage().contains("no such table")) {
                    Logger.e(TAG, "table in database not exists", e);
                    resetDB();
                    return getSizeInternal();
                } else {
                    throw e;
                }
            }
        }
        
        private long[] getSizeInternal() {
            long[] result = new long[] { 0, 0 };
            Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, COL_FILESIZE + " = -1", null, null, null, null);
            if (c != null) {
                if (c.moveToFirst()) {
                    c.close();
                    resetDB();
                    return result;
                }
                c.close();
            }
            
            c = dbHelper.getReadableDatabase().rawQuery("SELECT SUM(" + COL_FILESIZE + ") FROM " + TABLE_NAME, null);
            if (c != null) {
                if (c.moveToFirst()) result[0] = c.getInt(0);
                c.close();
            }
            
            c = dbHelper.getReadableDatabase().rawQuery("SELECT SUM(" + COL_FILESIZE + ") FROM " + TABLE_NAME +
                    " WHERE " + COL_FILENAME + " LIKE '" + PREFIX_PAGES + "%' OR " + COL_FILENAME + " LIKE '" + PREFIX_DRAFTS + "%'", null);
            if (c != null) {
                if (c.moveToFirst()) result[1] = c.getInt(0);
                c.close();
            }
            
            return result;
        }
        
        public void remove(String filename) {
            dbHelper.getWritableDatabase().delete(TABLE_NAME, COL_FILENAME + " = ?", new String[] { filename });
        }
        
        public void resetDB() {
            dbHelper.resetDB();
        }
        
        private static class DBHelper extends SQLiteOpenHelper implements BaseColumns {
            public DBHelper(Context context) {
                super(context, DB_NAME, null, DB_VERSION);
            }
            
            @Override
            public void onCreate(SQLiteDatabase db) {
                db.execSQL(createTable(TABLE_NAME,
                        new String[] { COL_FILENAME, COL_FILESIZE, COL_TIMESTAMP },
                        new String[] { "text", "integer", "integer" }));
            }
            
            @Override
            public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
                if (oldVersion < newVersion) {
                    db.execSQL(dropTable(TABLE_NAME));
                    onCreate(db);
                }
            }
            
            @Override
            public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
                onUpgrade(db, oldVersion, newVersion);
            }
            
            private static String createTable(String tableName, String[] columns, String[] types) {
                StringBuilder sql = new StringBuilder(110).append("create table ").append(tableName).append(" (").
                        append(_ID).append(" integer primary key autoincrement,");
                for (int i=0; i<columns.length; ++i) {
                    sql.append(columns[i]).append(' ').append(types == null ? "text" : types[i]).append(',');
                }
                sql.setCharAt(sql.length()-1, ')');
                return sql.append(';').toString();
            }
            
            private static String dropTable(String tableName) {
                return "DROP TABLE IF EXISTS " + tableName;
            }
            
            private void resetDB() {
                SQLiteDatabase db = getWritableDatabase();
                db.execSQL(dropTable(TABLE_NAME));
                onCreate(db);
            }
            
        }
    }
    
    private static File getAvailableCacheDir(Context context) {
        File externalCacheDir = null;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) {
            externalCacheDir = CompatibilityImpl.getExternalCacheDir(context);
        } else if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            externalCacheDir = new File(Environment.getExternalStorageDirectory(), "/Android/data/" + context.getPackageName() + "/cache/");
        }
        return externalCacheDir != null ? externalCacheDir : context.getCacheDir();
    }
    
    private static File getAvailableFilesDir(Context context) {
        File externalFilesDir = null;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) {
            externalFilesDir = CompatibilityImpl.getExternalFilesDir(context);
        } else if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            externalFilesDir = new File(Environment.getExternalStorageDirectory(), "/Android/data/" + context.getPackageName() + "/files/");
        }
        return externalFilesDir != null ? externalFilesDir : context.getFilesDir();
    }
    
    private void transferTabsState() {
        File to = new File(filesDirectory, TABS_FILENAME);
        if (to.exists()) return;
        
        File from;
        File file1 = new File(directory, "tabsstate");
        File file2 = new File(directory, "tabsstate_2");
        boolean file1Exists = file1.exists() && !file1.isDirectory();
        boolean file2Exists = file2.exists() && !file2.isDirectory();
        if (!file1Exists && !file2Exists) return;
        else if (!file1Exists) from = file2;
        else if (!file2Exists) from = file1;
        else from = file1.lastModified() > file2.lastModified() ? file2 : file1;
        copyFile(from, to);
        try {
            if (file1Exists && file1.delete()) database.remove("tabsstate");
            if (file2Exists && file2.delete()) database.remove("tabsstate_2");
        } catch (Exception e) {
            Logger.e(TAG, e);
        }
    }
    
    private static void copyFile(File from, File to) {
        InputStream in = null;
        OutputStream out = null;
        try {
            File parent = to.getParentFile();
            if (!parent.exists() || !parent.isDirectory()) parent.mkdirs();
            in = new FileInputStream(from);
            out = new FileOutputStream(to);
            IOUtils.copyStream(in, out);
        } catch (Exception e) {
            Logger.e(TAG, e);
        } finally {
            IOUtils.closeQuietly(in);
            IOUtils.closeQuietly(out);
        }
    }
}