/*
 * 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.ui.downloading;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import nya.miku.wishmaster.R;
import nya.miku.wishmaster.api.ChanModule;
import nya.miku.wishmaster.api.interfaces.CancellableTask;
import nya.miku.wishmaster.api.interfaces.ProgressListener;
import nya.miku.wishmaster.api.models.AttachmentModel;
import nya.miku.wishmaster.api.models.BadgeIconModel;
import nya.miku.wishmaster.api.models.BoardModel;
import nya.miku.wishmaster.api.models.PostModel;
import nya.miku.wishmaster.api.models.UrlPageModel;
import nya.miku.wishmaster.api.util.ChanModels;
import nya.miku.wishmaster.api.util.PageLoaderFromChan;
import nya.miku.wishmaster.cache.BitmapCache;
import nya.miku.wishmaster.cache.FileCache;
import nya.miku.wishmaster.cache.SerializablePage;
import nya.miku.wishmaster.common.Async;
import nya.miku.wishmaster.common.IOUtils;
import nya.miku.wishmaster.common.Logger;
import nya.miku.wishmaster.common.MainApplication;
import nya.miku.wishmaster.containers.WriteableContainer;
import nya.miku.wishmaster.http.interactive.InteractiveException;
import nya.miku.wishmaster.lib.base64.Base64;
import nya.miku.wishmaster.lib.base64.Base64OutputStream;
import nya.miku.wishmaster.ui.Attachments;
import nya.miku.wishmaster.ui.settings.ApplicationSettings;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.support.v4.content.res.ResourcesCompat;

public class DownloadingService extends Service {
    private static final String TAG = "DownloadingService";
    
    public static final String EXTRA_DOWNLOADING_ITEM = "DownloadingItem";
    public static final String EXTRA_DOWNLOADING_REPORT = "DownloadingReport";
    
    public static final int REPORT_NONE = 0;
    public static final int REPORT_OK = 1;
    public static final int REPORT_ERROR = 2;
    
    public static final String BROADCAST_UPDATED = "nya.miku.wishmaster.BROADCAST_ACTION_DOWNLOADING_UPDATED";
    
    public static final String SHARED_PREFERENCES_NAME = "downloading_last_error_report";
    public static final String PREF_ERROR_REPORT = "LAST_ERROR_REPORT";
    public static final String PREF_ERROR_ITEMS = "LAST_ERROR_ITEMS";
    
    /** путь и имя файла с основным (сериализованным) объектом сохранённой страницы внутри архива */
    public static final String MAIN_OBJECT_FILE = "data/serialized.bin";
    /** имя файла со значком favicon сохраняемой HTML страницы внутри архива */
    public static final String FAVICON_FILE = "favicon.png";
    /** формат расположения файлов-превью внутри архива сохранённой страницы (%s соответствует хэшу вложения) */
    public static final String THUMBNAIL_FILE_FORMAT = "thumbnails/%s.png";
    /** формат расположения файлов-иконок (флаги/полит.предпочтения) внутри архива сохранённой страницы (%s соответствует хэшу иконки) */
    public static final String ICON_FILE_FORMAT = "icons/%s.png";
    /** название папки (внутри архива сохранённой страницы) с оригиналами файлов-вложений */
    public static final String ORIGINALS_FOLDER = "originals";
    
    public static final int MODE_ONLY_CACHE = 1;
    public static final int MODE_DOWNLOAD_THUMBS = 2;
    public static final int MODE_DOWNLOAD_ALL = 3;
    
    public static final int DOWNLOADING_NOTIFICATION_ID = 20;
    public static final int ERROR_REPORT_NOTIFICATION_ID = 30;
    
    private volatile boolean nowTaskRunning = false;
    
    private NotificationCompat.Builder progressNotifBuilder;
    
    private Queue<DownloadingQueueItem> downloadingQueue;
    private DownloadingTask currentTask;
    private DownloadingServiceBinder binder;
    private NotificationManager notificationManager;
    private ApplicationSettings settings;
    private FileCache fileCache;
    private DownloadingLocker downloadingLocker;
    private BitmapCache bitmapCache;
    
    private boolean isForeground = false;
    
    private static DownloadingTask sCurrentTask;
    private static Queue<DownloadingQueueItem> sQueue;
    
    public static boolean isInQueue(DownloadingQueueItem item) {
        DownloadingTask currentTask = sCurrentTask;
        if (currentTask != null && currentTask.getCurrentItem() != null && currentTask.getCurrentItem().equals(item)) {
            return true;
        }
        return sQueue != null && sQueue.contains(item);
    }
    
    @Override
    public void onCreate() {
        super.onCreate();
        downloadingQueue = new LinkedBlockingQueue<DownloadingQueueItem>();
        sQueue = downloadingQueue;
        binder = new DownloadingServiceBinder(this);
        notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
        settings = MainApplication.getInstance().settings;
        fileCache = MainApplication.getInstance().fileCache;
        downloadingLocker = MainApplication.getInstance().downloadingLocker;
        bitmapCache = MainApplication.getInstance().bitmapCache;
        Logger.d(TAG, "created downloading service");
    }
    
    @Override
    public void onDestroy() {
        super.onDestroy();
        sCurrentTask = null;
        Logger.d(TAG, "destroyed downloading service");
    }
    
    private void notifyForeground(int id, Notification notification) {
        if (!isForeground) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ECLAIR) {
                try {
                    getClass().getMethod("setForeground", new Class[] { boolean.class }).invoke(this, Boolean.TRUE);
                } catch (Exception e) {
                    Logger.e(TAG, "cannot invoke setForeground(true)", e);
                }
                notificationManager.notify(id, notification);
            } else {
                ForegroundCompat.startForeground(this, id, notification);
            }
            isForeground = true;
        } else {
            notificationManager.notify(id, notification);
        }
    }
    
    private void cancelForeground(int id) {
        if (isForeground) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ECLAIR) {
                notificationManager.cancel(id);
                try {
                    getClass().getMethod("setForeground", new Class[] { boolean.class }).invoke(this, Boolean.FALSE);
                } catch (Exception e) {
                    Logger.e(TAG, "cannot invoke setForeground(false)", e);
                }
            } else {
                ForegroundCompat.stopForeground(this);
            }
            isForeground = false;
        } else {
            notificationManager.cancel(id);
        }
    }
    
    @TargetApi(Build.VERSION_CODES.ECLAIR)
    private static class ForegroundCompat {
        static void startForeground(Service service, int id, Notification notification) {
            service.startForeground(id, notification);
        }
        static void stopForeground(Service service) {
            service.stopForeground(true);
        }
    }
    
    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }
    
    @Override
    @SuppressLint("InlinedApi")
    public int onStartCommand(Intent intent, int flags, int startId) {
        onStart(intent, startId);
        return Service.START_REDELIVER_INTENT;
    }
    
    @Override
    public void onStart(Intent intent, int startId) {
        if (intent != null) {
            DownloadingQueueItem item = (DownloadingQueueItem) intent.getSerializableExtra(EXTRA_DOWNLOADING_ITEM);
            if (item != null) downloadingQueue.add(item);
        }
        if (currentTask == null || !nowTaskRunning) {
            Logger.d(TAG, "starting downloading task");
            nowTaskRunning = true;
            currentTask = new DownloadingTask(startId);
            sCurrentTask = currentTask;
            Async.runAsync(currentTask);
        } else {
            Logger.d(TAG, "item added to download queue");
            if (progressNotifBuilder != null) {
                progressNotifBuilder.setContentTitle(getString(R.string.downloading_title, downloadingQueue.size() + 1));
                notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
            }
            sendBroadcast(new Intent(BROADCAST_UPDATED));
            currentTask.setStartId(startId);
        }
    }
    
    public class DownloadingTask extends CancellableTask.BaseCancellableTask implements Runnable {
        private int startId;
        private long maxProgressValue = 100;
        private int curProgress = -1;
        private String currentItemName;
        private DownloadingQueueItem currentItem;
        private StringBuilder errorReport;
        private ArrayList<DownloadingQueueItem> errorItems;
        
        public DownloadingTask(int startId) {
            setStartId(startId);
        }
        
        public void setStartId(int startId) {
            this.startId = startId;
        }
        
        public int getCurrentProgress() {
            return curProgress;
        }
        
        public String getCurrentItemName() {
            return currentItemName;
        }
        
        public DownloadingQueueItem getCurrentItem() {
            return currentItem;
        }
        
        @Override
        public void run() {
            errorReport = new StringBuilder();
            errorItems = new ArrayList<>();
            Intent intentToProgressDialog = new Intent(DownloadingService.this, DownloadingProgressActivity.class);
            PendingIntent pIntentToProgressDialog =
                    PendingIntent.getActivity(DownloadingService.this, 0, intentToProgressDialog, PendingIntent.FLAG_CANCEL_CURRENT);
            progressNotifBuilder = new NotificationCompat.Builder(DownloadingService.this).
                    setSmallIcon(android.R.drawable.stat_sys_download).
                    setTicker(getString(R.string.downloading_start_ticker)).
                    setContentIntent(pIntentToProgressDialog).
                    setOngoing(true).
                    setCategory(NotificationCompat.CATEGORY_PROGRESS).
                    setProgress(100, 0, true);
            
            while (!isCancelled() && !downloadingQueue.isEmpty()) {
                DownloadingQueueItem item = downloadingQueue.poll();
                currentItem = item;
                progressNotifBuilder.setContentTitle(downloadingQueue.size() > 0 ?
                        getString(R.string.downloading_title, downloadingQueue.size() + 1) : getString(R.string.downloading_title_simple));
                
                if (item.type == DownloadingQueueItem.TYPE_ATTACHMENT) {
                    final String filename = Attachments.getAttachmentLocalFileName(item.attachment, item.boardModel);
                    if (filename == null) continue;
                    String elementName = getString(R.string.downloading_element_format, item.chanName,
                            Attachments.getAttachmentLocalShortName(item.attachment, item.boardModel));
                    currentItemName = elementName;
                    
                    curProgress = -1;
                    progressNotifBuilder.setContentText(filename).setProgress(100, 0, true);
                    notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                    sendBroadcast(new Intent(BROADCAST_UPDATED));
                    
                    ProgressListener listener = new ProgressListener() {
                        @Override
                        public void setProgress(long value) {
                            int newProgress = (int)(100 * (double)value / maxProgressValue);
                            if (newProgress == curProgress) return;
                            curProgress = newProgress;
                            progressNotifBuilder.setProgress(100, newProgress, false);
                            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
                                progressNotifBuilder.setContentText("("+newProgress+"%) "+filename);
                            }
                            notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                            sendBroadcast(new Intent(BROADCAST_UPDATED));
                        }
                        @Override
                        public void setMaxValue(long value) {
                            if (value > 0) maxProgressValue = value;
                        }
                        @Override
                        public void setIndeterminate() {
                            if (curProgress == -1) return;
                            progressNotifBuilder.setProgress(100, 0, true);
                            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
                                progressNotifBuilder.setContentText(filename);
                            }
                            notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                            sendBroadcast(new Intent(BROADCAST_UPDATED));
                            curProgress = -1;
                        }
                    };
                    
                    File directory = new File(settings.getDownloadDirectory(), item.chanName);
                    if (item.subdirectory != null && item.subdirectory.length() > 0) directory = new File(directory, item.subdirectory);
                    if (!directory.mkdirs() && !directory.isDirectory()) {
                        addError(item, elementName, getString(R.string.downloading_error_mkdir));
                        continue;
                    }
                    File target = new File(directory, filename);
                    if (target.exists()) {
                        addError(item, elementName, getString(R.string.downloading_error_file_exists));
                        continue;
                    }
                    File fromCache = fileCache.get(FileCache.PREFIX_ORIGINALS + ChanModels.hashAttachmentModel(item.attachment) +
                            Attachments.getAttachmentExtention(item.attachment));
                    if (fromCache != null) {
                        String fromCacheFilename = fromCache.getAbsolutePath();
                        while (downloadingLocker.isLocked(fromCacheFilename)) downloadingLocker.waitUnlock(fromCacheFilename);
                        if (isCancelled()) continue;
                        boolean success = false;
                        InputStream is = null;
                        OutputStream os = null;
                        try {
                            if (listener != null) listener.setMaxValue(fromCache.length());
                            is = IOUtils.modifyInputStream(new FileInputStream(fromCache), listener, this);
                            os = new FileOutputStream(target);
                            IOUtils.copyStream(is, os);
                            success = true;
                        } catch (Exception e) {
                            if (!isCancelled()) {
                                addError(item, elementName,
                                        getString(IOUtils.isENOSPC(e) ? R.string.error_no_space : R.string.downloading_error_copy));
                            }
                        } finally {
                            IOUtils.closeQuietly(is);
                            IOUtils.closeQuietly(os);
                            if (!success) target.delete();
                            else notifyMediaScanner(target);
                        }
                    } else {
                        String targetFilename = target.getAbsolutePath();
                        while (!downloadingLocker.lock(targetFilename)) downloadingLocker.waitUnlock(targetFilename);
                        if (isCancelled()) {
                            downloadingLocker.unlock(targetFilename);
                            continue;
                        }
                        boolean success = false;
                        FileOutputStream out = null;
                        try {
                            out = new FileOutputStream(target);
                            MainApplication.getInstance().getChanModule(item.chanName).downloadFile(item.attachment.path, out, listener, this);
                            success = true;
                        } catch (Exception e) {
                            Logger.e(TAG, e);
                            if (!isCancelled()) addError(item, elementName, e instanceof InteractiveException ?
                                    getString(R.string.downloading_error_interactive_format, ((InteractiveException) e).getServiceName()) :
                                        getMessageOrENOSPC(e));
                        } finally {
                            IOUtils.closeQuietly(out);
                            if (!success) target.delete();
                            else notifyMediaScanner(target);
                            downloadingLocker.unlock(targetFilename);
                        }
                    }
                    
                } else if (item.type == DownloadingQueueItem.TYPE_THREAD) {
                    String filename = item.boardModel.boardName + "-" + item.threadUrlPage.threadNumber + settings.getDownloadThreadFormat();
                    String htmlname = item.chanName + "_" + item.boardModel.boardName + "_" + item.threadUrlPage.threadNumber + ".html";
                    String elementName = getString(R.string.downloading_element_format, item.chanName,
                            getString(R.string.downloading_thread_format, item.boardModel.boardName, item.threadUrlPage.threadNumber));
                    currentItemName = elementName;
                    
                    curProgress = -1;
                    progressNotifBuilder.setContentText(elementName).setProgress(100, 0, true);
                    notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                    sendBroadcast(new Intent(BROADCAST_UPDATED));
                    
                    File directory = new File(settings.getDownloadDirectory(), item.chanName);
                    if (!directory.mkdirs() && !directory.isDirectory()) {
                        addError(item, elementName, getString(R.string.downloading_error_mkdir));
                        continue;
                    }
                    
                    WriteableContainer zip = null;
                    File zipFile = new File(directory, filename);
                    try {
                        try {
                            zip = WriteableContainer.obtain(zipFile);
                        } catch (Exception e) {
                            throw new Exception(getString(IOUtils.isENOSPC(e) ? R.string.error_no_space : R.string.downloading_error_mkfile));
                        }
                        final SerializablePage page = getSerializablePage(item);
                        if (isCancelled()) throw new Exception();
                        
                        HtmlBuilder htmlBuilder = null;
                        try {
                            htmlBuilder = new HtmlBuilder(zip.openStream(htmlname), 
                                    new HtmlBuilder.RefsGetter() {
                                        final ChanModule chan = MainApplication.getInstance().getChanModule(page.boardModel.chan);
                                        
                                        @Override
                                        public String getFavicon() {
                                            return HtmlBuilder.DATA_DIR + "/" + FAVICON_FILE;
                                        }
                                        
                                        @Override
                                        public String getThumbnail(AttachmentModel attachment) {
                                            if (attachment.isSpoiler) return getFavicon(); //TODO запилить картинку спойлера
                                            return attachment.thumbnail == null ? null : String.format(Locale.US, THUMBNAIL_FILE_FORMAT,
                                                    ChanModels.hashAttachmentModel(attachment));
                                        }
                                        
                                        @Override
                                        public String getOriginal(AttachmentModel attachment) {
                                            String chanRef = chan.fixRelativeUrl(attachment.path != null ? attachment.path : attachment.thumbnail);
                                            if (attachment.type != AttachmentModel.TYPE_OTHER_NOTFILE) {
                                                String filename = Attachments.getAttachmentLocalFileName(attachment, page.boardModel);
                                                if (filename != null && filename.length() != 0) {
                                                    //TODO проверять, когда вложение отсутствует и в папке с загрузками, и в кэше, отдавать ссылку
                                                    return ORIGINALS_FOLDER + "/" + filename;
                                                } else {
                                                    return chanRef;
                                                }
                                            } else return chanRef;
                                        }
                                        
                                        @Override
                                        public String getIcon(BadgeIconModel icon) {
                                            return String.format(Locale.US, ICON_FILE_FORMAT,
                                                    ChanModels.hashBadgeIconModel(icon, chan.getChanName()));
                                        }
                                    });
                            htmlBuilder.write(page);
                        } catch (Exception e) {
                            Logger.e(TAG, e);
                            throw new Exception(getString(IOUtils.isENOSPC(e) ? R.string.error_no_space : R.string.downloading_error_save_html));
                        } finally {
                            IOUtils.closeQuietly(htmlBuilder);
                        }
                        
                        String pageTitle = HtmlBuilder.buildTitle(page);
                        
                        try {
                            MainApplication.getInstance().serializer.savePage(zip.openStream(MAIN_OBJECT_FILE), pageTitle, page.pageModel, page);
                        } catch (Exception e) {
                            Logger.e(TAG, e);
                            throw new Exception(getString(IOUtils.isENOSPC(e) ? R.string.error_no_space : R.string.downloading_error_serialize));
                        }
                        
                        for (String asset : HtmlBuilder.ASSETS) {
                            if (zip.hasFile(asset)) continue;
                            InputStream in = null;
                            OutputStream out = null;
                            try {
                                in = getAssets().open(asset);
                                out = zip.openStream(HtmlBuilder.DATA_DIR + "/" + asset);
                                IOUtils.copyStream(in, out);
                            } catch (Exception e) {
                                Logger.e(TAG, e);
                                if (!isCancelled()) {
                                    if (IOUtils.isENOSPC(e)) {
                                        throw new Exception(getString(R.string.error_no_space));
                                    } else {
                                        addError(item, asset, getString(R.string.downloading_error_copy));
                                    }
                                }
                            } finally {
                                IOUtils.closeQuietly(in);
                                IOUtils.closeQuietly(out);
                            }
                        }
                        
                        OutputStream faviconStream = null;
                        try {
                            faviconStream = zip.openStream(HtmlBuilder.DATA_DIR + "/" + FAVICON_FILE);
                            Drawable favicon = new LayerDrawable(new Drawable[] {
                                    MainApplication.getInstance().getChanModule(item.chanName).getChanFavicon(),
                                    ResourcesCompat.getDrawable(getResources(), R.drawable.favicon_overlay_local, null)
                            });
                            Bitmap bmp = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
                            favicon.setBounds(0, 0, 32, 32);
                            favicon.draw(new Canvas(bmp));
                            bmp.compress(Bitmap.CompressFormat.PNG, 100, faviconStream);
                        } catch (Exception e) {
                            Logger.e(TAG, e);
                            if (!isCancelled()) {
                                if (IOUtils.isENOSPC(e)) {
                                    throw new Exception(getString(R.string.error_no_space));
                                } else {
                                    addError(item, FAVICON_FILE, getString(R.string.downloading_error_copy));
                                }
                            }
                        } finally {
                            IOUtils.closeQuietly(faviconStream);
                        }
                        
                        try {
                            zip.transfer(null, this);
                        } catch (Exception e) {
                            Logger.e(TAG, e);
                            throw new Exception(getString(IOUtils.isENOSPC(e) ? R.string.error_no_space : R.string.downloading_error_copy));
                        }
                        
                        List<AttachmentModel> attachments = new ArrayList<AttachmentModel>();
                        List<BadgeIconModel> icons = new ArrayList<BadgeIconModel>();
                        Set<String> iconsHashes = new HashSet<String>();
                        int threadsCount = page.threads == null ? 0 : page.threads.length;
                        for (int i=-1; i<threadsCount; ++i) {
                            PostModel[] posts = i == -1 ? page.posts : page.threads[i].posts;
                            if (posts == null) continue;
                            for (PostModel postModel : page.posts) {
                                if (postModel.attachments != null) {
                                    for (AttachmentModel attachment : postModel.attachments) {
                                        attachments.add(attachment);
                                    }
                                }
                                if (postModel.icons != null) {
                                    for (BadgeIconModel icon : postModel.icons) {
                                        String iconHash = ChanModels.hashBadgeIconModel(icon, item.chanName);
                                        if (iconsHashes.contains(iconHash)) continue;
                                        icons.add(icon);
                                        iconsHashes.add(iconHash);
                                    }
                                }
                            }
                        }
                        
                        for (int i=0; i<icons.size(); ++i) {
                            if (isCancelled()) throw new Exception();
                            BadgeIconModel icon = icons.get(i);
                            if (icon.source == null || icon.source.length() == 0) continue;
                            String hash = ChanModels.hashBadgeIconModel(icon, item.chanName);
                            String curElementName = icon.source.substring(icon.source.lastIndexOf('/') + 1);
                            if (!zip.hasFile(String.format(Locale.US, ICON_FILE_FORMAT, hash))) {
                                Bitmap bmp = bitmapCache.getFromCache(hash);
                                if (bmp == null && item.downloadingThreadMode == MODE_ONLY_CACHE) continue;
                                if (bmp == null) bmp = bitmapCache.download(hash, icon.source,
                                        getResources().getDimensionPixelSize(R.dimen.post_badge_size),
                                        MainApplication.getInstance().getChanModule(item.chanName), this);
                                if (isCancelled()) throw new Exception();
                                if (bmp != null) {
                                    OutputStream out = null;
                                    try {
                                        out = zip.openStream(String.format(Locale.US, ICON_FILE_FORMAT, hash));
                                        bmp.compress(Bitmap.CompressFormat.PNG, 100, out);
                                    } catch (Exception e) {
                                        Logger.e(TAG, e);
                                        if (!isCancelled()) {
                                            if (IOUtils.isENOSPC(e)) {
                                                throw new Exception(getString(R.string.error_no_space));
                                            } else {
                                                addError(item, curElementName, getString(R.string.downloading_error_copy));
                                            }
                                        }
                                    } finally {
                                        IOUtils.closeQuietly(out);
                                    }
                                } else {
                                    if (!isCancelled()) addError(item, curElementName, getString(R.string.downloading_error_download));
                                }
                            }
                        }
                        
                        for (int i=0; i<attachments.size(); ++i) {
                            if (isCancelled()) throw new Exception();
                            
                            AttachmentModel attachment = attachments.get(i);
                            String curFile = Attachments.getAttachmentLocalFileName(attachment, item.boardModel);
                            if (curFile == null) continue;
                            String curElementName = getString(R.string.downloading_element_format, item.chanName,
                                    Attachments.getAttachmentLocalShortName(attachment, item.boardModel));
                            String curThumbElementName = getString(R.string.downloading_thumbnail_format, curElementName);
                            String curHash = ChanModels.hashAttachmentModel(attachment);
                            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
                                progressNotifBuilder.setContentText("("+i+"/"+attachments.size()+") "+elementName);
                            }
                            curProgress = Math.round(100f * i / attachments.size());
                            progressNotifBuilder.setProgress(attachments.size(), i, false);
                            notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                            sendBroadcast(new Intent(BROADCAST_UPDATED));
                            
                            if (attachment.type != AttachmentModel.TYPE_OTHER_NOTFILE && !zip.hasFile(ORIGINALS_FOLDER+"/"+curFile)) {
                                File cur = new File(directory, curFile);
                                if (!cur.exists() || cur.isDirectory() || cur.length() == 0) {
                                    cur = fileCache.get(FileCache.PREFIX_ORIGINALS + ChanModels.hashAttachmentModel(attachment) +
                                            Attachments.getAttachmentExtention(attachment));
                                    if (cur != null) {
                                        String curFilename = cur.getAbsolutePath();
                                        while (downloadingLocker.isLocked(curFilename)) downloadingLocker.waitUnlock(curFilename);
                                        if (isCancelled()) throw new Exception();
                                    }
                                    if (cur == null && item.downloadingThreadMode == MODE_DOWNLOAD_ALL) {
                                        cur = fileCache.create(FileCache.PREFIX_ORIGINALS + ChanModels.hashAttachmentModel(attachment) +
                                                Attachments.getAttachmentExtention(attachment));
                                        String curFilename = cur.getAbsolutePath();
                                        while (!downloadingLocker.lock(curFilename)) downloadingLocker.waitUnlock(curFilename);
                                        if (isCancelled()) {
                                            fileCache.abort(cur);
                                            downloadingLocker.unlock(curFilename);
                                            throw new Exception();
                                        }
                                        FileOutputStream out = null;
                                        boolean success = true;
                                        try {
                                            out = new FileOutputStream(cur);
                                            MainApplication.getInstance().getChanModule(item.chanName).downloadFile(attachment.path, out, null, this);
                                            fileCache.put(cur);
                                        } catch (Exception e) {
                                            Logger.e(TAG, e);
                                            if (!isCancelled()) {
                                                if (IOUtils.isENOSPC(e)) {
                                                    throw new Exception(getString(R.string.error_no_space));
                                                } else {
                                                    addError(item, curElementName, e instanceof InteractiveException ?
                                                            getString(R.string.downloading_error_interactive_format,
                                                                    ((InteractiveException) e).getServiceName()) : getMessageOrENOSPC(e));
                                                }
                                            }
                                            success = false;
                                        } finally {
                                            if (out != null) IOUtils.closeQuietly(out);
                                            if (!success && cur != null) {
                                                fileCache.abort(cur);
                                                cur = null;
                                            }
                                            downloadingLocker.unlock(curFilename);
                                        }
                                    }
                                }
                                if (isCancelled()) throw new Exception();
                                if (cur != null) {
                                    InputStream in = null;
                                    OutputStream out = null;
                                    try {
                                        in = IOUtils.modifyInputStream(new FileInputStream(cur), null, this);
                                        out = zip.openStream(ORIGINALS_FOLDER+"/"+curFile);
                                        IOUtils.copyStream(in, out);
                                    } catch (Exception e) {
                                        Logger.e(TAG, e);
                                        if (!isCancelled()) {
                                            if (IOUtils.isENOSPC(e)) {
                                                throw new Exception(getString(R.string.error_no_space));
                                            } else {
                                                addError(item, curElementName, getString(R.string.downloading_error_copy));
                                            }
                                        }
                                    } finally {
                                        IOUtils.closeQuietly(in);
                                        IOUtils.closeQuietly(out);
                                    }
                                }
                            }
                            
                            if (isCancelled()) throw new Exception();
                            
                            if (!zip.hasFile(String.format(Locale.US, THUMBNAIL_FILE_FORMAT, curHash))) {
                                Bitmap bmp = bitmapCache.getFromCache(curHash);
                                if (bmp == null && (attachment.thumbnail == null || attachment.thumbnail.length() == 0 ||
                                        item.downloadingThreadMode == MODE_ONLY_CACHE)) continue;
                                if (bmp == null) bmp = bitmapCache.download(curHash, attachment.thumbnail,
                                        getResources().getDimensionPixelSize(R.dimen.post_thumbnail_size),
                                        MainApplication.getInstance().getChanModule(item.chanName), this);
                                if (isCancelled()) throw new Exception();
                                if (bmp != null) {
                                    OutputStream out = null;
                                    try {
                                        out = zip.openStream(String.format(Locale.US, THUMBNAIL_FILE_FORMAT, curHash));
                                        bmp.compress(Bitmap.CompressFormat.PNG, 100, out);
                                    } catch (Exception e) {
                                        Logger.e(TAG, e);
                                        if (!isCancelled()) {
                                            if (IOUtils.isENOSPC(e)) {
                                                throw new Exception(getString(R.string.error_no_space));
                                            } else {
                                                addError(item, curThumbElementName, getString(R.string.downloading_error_copy));
                                            }
                                        }
                                    } finally {
                                        IOUtils.closeQuietly(out);
                                    }
                                } else {
                                    if (!isCancelled()) addError(item, curThumbElementName, getString(R.string.downloading_error_download));
                                }
                            }
                        }
                        try {
                            MainApplication.getInstance().
                                    database.addSavedThread(item.chanName, pageTitle, zipFile.getAbsolutePath());
                        } catch (Exception e) {
                            Logger.e(TAG, "database exception", e);
                        }
                        
                    } catch (Exception e) {
                        Logger.e(TAG, e);
                        if (!isCancelled()) addError(item, elementName, getMessageOrENOSPC(e));
                        if (zip != null) zip.cancel();
                    } finally {
                        try {
                            if (zip != null) zip.close();
                        } catch (Exception e) {
                            if (!isCancelled()) addError(item, elementName, getString(R.string.downloading_error_save_container));
                        }
                    }
                }
            }
            currentItem = null;
            currentItemName = null;
            
            nowTaskRunning = false;
            if (!isCancelled()) {
                while (errorReport.length() > 0 && errorReport.charAt(errorReport.length()-1) == '\n') {
                    errorReport.setLength(errorReport.length()-1);
                }
                if (errorReport.length() == 0) {
                    progressNotifBuilder.setTicker(getString(R.string.downloading_success_ticker)).
                        setSmallIcon(android.R.drawable.stat_sys_download_done);
                    notifyForeground(DOWNLOADING_NOTIFICATION_ID, progressNotifBuilder.build());
                    Intent broadcast = new Intent(BROADCAST_UPDATED);
                    broadcast.putExtra(EXTRA_DOWNLOADING_REPORT, REPORT_OK);
                    sendBroadcast(broadcast);
                } else {
                    Intent intentToErrorReport = new Intent(DownloadingService.this, DownloadingErrorReportActivity.class);
                    PendingIntent pIntentToErrorReport =
                            PendingIntent.getActivity(DownloadingService.this, 0, intentToErrorReport, PendingIntent.FLAG_CANCEL_CURRENT);
                    notificationManager.notify(ERROR_REPORT_NOTIFICATION_ID, new NotificationCompat.Builder(DownloadingService.this).
                            setSmallIcon(android.R.drawable.stat_notify_error).
                            setTicker(getString(R.string.downloading_error_ticker)).
                            setContentTitle(getString(R.string.downloading_error_title)).
                            setContentText(getString(R.string.downloading_error_ticker)).
                            setContentIntent(pIntentToErrorReport).
                            setOngoing(false).
                            setAutoCancel(true).
                            setCategory(NotificationCompat.CATEGORY_ERROR).
                            build());
                    Intent broadcast = new Intent(BROADCAST_UPDATED);
                    broadcast.putExtra(EXTRA_DOWNLOADING_REPORT, REPORT_ERROR);
                    getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE).edit().
                            putString(PREF_ERROR_REPORT, errorReport.toString()).
                            putString(PREF_ERROR_ITEMS, serializeErrorItems(errorItems)).
                            commit();
                    sendBroadcast(broadcast);
                }
            }
            errorReport.setLength(0);
            errorReport.trimToSize();
            errorItems.clear();
            errorItems.trimToSize();
            Logger.d(TAG, "stopped downloading task");
            cancelForeground(DOWNLOADING_NOTIFICATION_ID);
            stopSelf(startId);
        }
        
        public SerializablePage getSerializablePage(DownloadingQueueItem item) throws Exception {
            if (item.type != DownloadingQueueItem.TYPE_THREAD) throw new Exception();
            SerializablePage page = MainApplication.getInstance().pagesCache.getSerializablePage(ChanModels.hashUrlPageModel(item.threadUrlPage));
            if (isCancelled()) {
                throw new Exception();
            }
            if (page != null) {
                SerializablePage p = new SerializablePage(); //prevent concurrent modification
                p.pageModel = page.pageModel;
                p.boardModel = page.boardModel;
                p.posts = page.posts;
                p.threads = page.threads;
                return p;
            }
            page = new SerializablePage();
            page.pageModel = item.threadUrlPage;
            class LoaderCallback implements PageLoaderFromChan.PageLoaderCallback {
                public volatile String reason = null;
                @Override
                public void onSuccess() {
                    reason = null;
                }
                @Override
                public void onError(String message) {
                    reason = message;
                }
                @Override
                public void onInteractiveException(InteractiveException e) {
                    reason = getString(R.string.downloading_error_interactive_format, e.getServiceName());
                }
            }
            LoaderCallback cb = new LoaderCallback();
            new PageLoaderFromChan(page, cb, MainApplication.getInstance().getChanModule(item.chanName), this).run();
            if (isCancelled()) {
                throw new Exception();
            }
            if (cb.reason != null) {
                throw new Exception(cb.reason);
            }
            return page;
        }
        
        private void addError(DownloadingQueueItem item, String element, String error) {
            if (error == null) error = getString(R.string.downloading_error_unknown);
            errorReport.append(element).append('\n').append(error).append("\n\n");
            if (errorItems.size() > 0 && errorItems.get(errorItems.size()-1).equals(item)) return;
            //одинаковые item могут идти только подряд (вложения одного треда)
            errorItems.add(item);
        }
        
        private String getMessageOrENOSPC(Exception e) {
            if (IOUtils.isENOSPC(e)) return getString(R.string.error_no_space);
            return e.getMessage();
        }
        
        private void notifyMediaScanner(File file) {
            try {
                sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file)));
            } catch (Exception e) {
                Logger.e(TAG, e);
            }
        }
    }
    
    private static String serializeErrorItems(ArrayList<DownloadingQueueItem> list) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(new GZIPOutputStream(new Base64OutputStream(baos, Base64.DEFAULT)));
            oos.writeObject(list);
            oos.close();
            return baos.toString("US-ASCII");
        } catch (Exception e) {
            Logger.e(TAG, e);
            return "";
        }
    }
    
    @SuppressWarnings("unchecked")
    public static ArrayList<DownloadingQueueItem> deserializeErrorItems(String data) {
        try {
            ObjectInputStream ois = new ObjectInputStream(new GZIPInputStream(new ByteArrayInputStream(Base64.decode(data, Base64.DEFAULT))));
            return (ArrayList<DownloadingQueueItem>) ois.readObject();
        } catch (Exception e) {
            Logger.e(TAG, e);
            return null;
        }
    }
    
    /**
     * Класс-элемент очереди загрузок
     * @author miku-nyan
     *
     */
    public static class DownloadingQueueItem implements Serializable {
        private static final long serialVersionUID = 1L;
        
        public static final int TYPE_ATTACHMENT = 1;
        public static final int TYPE_THREAD = 2;
        
        public final int type;
        public final AttachmentModel attachment;
        public final String subdirectory;
        public final String chanName;
        public final BoardModel boardModel;
        public final UrlPageModel threadUrlPage;
        public final int downloadingThreadMode;
        
        /**
         * Конструктор элемента загрузки - файла-вложения
         * @param attachment модель вложения
         * @param subdirectory название подпапки, в которую требуется загрузить вложение (если в общую папку - null)
         * @param boardModel модель доски, с которой скачивается вложение
         */
        public DownloadingQueueItem(AttachmentModel attachment, String subdirectory, BoardModel boardModel) {
            this.type = TYPE_ATTACHMENT;
            this.attachment = attachment;
            if (attachment == null) throw new NullPointerException();
            this.subdirectory = subdirectory;
            this.chanName = boardModel.chan;
            this.boardModel = boardModel;
            this.threadUrlPage = null;
            this.downloadingThreadMode = -1;
        }
        
        /**
         * Конструктор элемента загрузки - файла-вложения
         * @param attachment модель вложения
         * @param boardModel модель доски, с которой скачивается вложение
         */
        public DownloadingQueueItem(AttachmentModel attachment, BoardModel boardModel) {
            this(attachment, null, boardModel);
        }
        
        /**
         * Конструктор элемента загрузки - страница-весь тред
         * @param threadUrlPage модель адреса треда
         * @param downloadingThreadMode режим загрузки (загружать вложения, только минитюры, или только из кэша).
         * см. {@link DownloadingService#MODE_DOWNLOAD_ALL}, {@link DownloadingService#MODE_DOWNLOAD_THUMBS},
         * {@link DownloadingService#MODE_ONLY_CACHE} 
         */
        public DownloadingQueueItem(UrlPageModel threadUrlPage, BoardModel boardModel, int downloadingThreadMode) {
            this.type = TYPE_THREAD;
            this.attachment = null;
            this.subdirectory = null;
            this.chanName = threadUrlPage.chanName;
            this.boardModel = boardModel;
            this.threadUrlPage = threadUrlPage;
            this.downloadingThreadMode = downloadingThreadMode; 
        }
        
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o instanceof DownloadingQueueItem) {
                DownloadingQueueItem cmp = (DownloadingQueueItem) o;
                if (cmp.type != type) return false;
                switch (type) {
                    case TYPE_ATTACHMENT:
                        if (!stringsEqual(cmp.subdirectory, subdirectory)) return false;
                        if (cmp.attachment == null) return attachment == null;
                        return ChanModels.hashAttachmentModel(cmp.attachment).equals(ChanModels.hashAttachmentModel(attachment));
                    case TYPE_THREAD:
                        if (cmp.threadUrlPage == null) return threadUrlPage == null;
                        return ChanModels.hashUrlPageModel(cmp.threadUrlPage).equals(ChanModels.hashUrlPageModel(threadUrlPage));
                }
            }
            return false;
        }
        
        private static boolean stringsEqual(String s1, String s2) {
            if (s1 == s2) return true;
            if (s1 == null) return s2 == null;
            return s1.equals(s2);
        }
        
        @Override
        public int hashCode() {
            return 0;
        }
    }
    
    public static class DownloadingServiceBinder extends Binder {
        private final WeakReference<DownloadingService> service;
        private DownloadingServiceBinder(DownloadingService service) {
            this.service = new WeakReference<>(service);
        }
        public void cancel() {
            DownloadingService service = this.service.get();
            if (service == null) return;
            if (service.currentTask != null) service.currentTask.cancel();
            if (!service.downloadingQueue.isEmpty()) service.downloadingQueue.clear();
        }
        public int getCurrentProgress() {
            DownloadingService service = this.service.get();
            if (service == null) return -1;
            if (service.currentTask == null) return -1; 
            return service.currentTask.getCurrentProgress();
        }
        public int getQueueSize() {
            DownloadingService service = this.service.get();
            if (service == null) return 0;
            if (service.downloadingQueue == null) return 0;
            return service.downloadingQueue.size();
        }
        public String getCurrentItemName() {
            DownloadingService service = this.service.get();
            if (service == null) return null;
            if (service.currentTask == null) return null;
            return service.currentTask.getCurrentItemName();
        }
    }
}