package allen.town.podcast.core.storage;

import static android.content.Context.MODE_PRIVATE;

import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.text.TextUtils;
import android.util.Log;

import androidx.annotation.VisibleForTesting;

import allen.town.podcast.core.service.download.DownloadRequest;
import allen.town.podcast.core.service.download.DownloadRequestCreator;
import allen.town.podcast.core.service.download.DownloadService;
import allen.town.podcast.storage.db.Db;
import allen.town.podcast.storage.db.mapper.FeedCursorMapper;
import org.greenrobot.eventbus.EventBus;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;

import allen.town.podcast.event.FeedItemEvent;
import allen.town.podcast.event.FeedListUpdateEvent;
import allen.town.podcast.core.pref.Prefs;
import allen.town.podcast.model.download.DownloadStatus;
import allen.town.podcast.core.sync.SyncService;
import allen.town.podcast.core.sync.queue.SynchronizationQueueSink;
import allen.town.podcast.model.download.DownloadError;
import allen.town.podcast.core.util.LongList;
import allen.town.podcast.core.util.comparator.FeedItemPubdateComparator;
import allen.town.podcast.model.feed.Feed;
import allen.town.podcast.model.feed.FeedItem;
import allen.town.podcast.model.feed.FeedMedia;
import allen.town.podcast.sync.model.EpisodeAction;

/**
 * Provides methods for doing common tasks that use DBReader and DBWriter.
 */
public final class DBTasks {
    private static final String TAG = "DBTasks";

    private static final String PREF_NAME = "dbtasks";
    private static final String PREF_LAST_REFRESH = "last_refresh";

    /**
     * Executor service used by the autodownloadUndownloadedEpisodes method.
     */
    private static final ExecutorService autodownloadExec;

    private static AutomaticDownloadAlgorithm downloadAlgorithm = new AutomaticDownloadAlgorithm();

    static {
        autodownloadExec = Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r);
            t.setPriority(Thread.MIN_PRIORITY);
            return t;
        });
    }

    private DBTasks() {
    }

    /**
     * Removes the feed with the given download url. This method should NOT be executed on the GUI thread.
     *
     * @param context     Used for accessing the db
     * @param downloadUrl URL of the feed.
     */
    public static void removeFeedWithDownloadUrl(Context context, String downloadUrl) {
        Db adapter = Db.getInstance();
        adapter.open();
        Cursor cursor = adapter.getFeedCursorDownloadUrls();
        long feedID = 0;
        if (cursor.moveToFirst()) {
            do {
                if (cursor.getString(1).equals(downloadUrl)) {
                    feedID = cursor.getLong(0);
                }
            } while (cursor.moveToNext());
        }
        cursor.close();
        adapter.close();

        if (feedID != 0) {
            try {
                DBWriter.deleteFeed(context, feedID).get();
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        } else {
            Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl);
        }
    }

    /**
     * Refreshes all feeds.
     * It must not be from the main thread.
     * This method might ignore subsequent calls if it is still
     * enqueuing Feeds for download from a previous call
     *
     * @param context  Might be used for accessing the database
     * @param initiatedByUser a boolean indicating if the refresh was triggered by user action.
     */
    public static void refreshAllFeeds(final Context context, boolean initiatedByUser) {
        DownloadService.refreshAllFeeds(context, initiatedByUser);

        SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, MODE_PRIVATE);
        prefs.edit().putLong(PREF_LAST_REFRESH, System.currentTimeMillis()).apply();

        SyncService.sync(context);
        // Note: automatic download of episodes will be done but not here.
        // Instead it is done after all feeds have been refreshed (asynchronously),
        // in DownloadService.onDestroy()
        // See Issue #2577 for the details of the rationale
    }



    /**
     * Queues the next page of this Feed for download. The given Feed has to be a paged
     * Feed (isPaged()=true) and must contain a nextPageLink.
     *
     * @param context      Used for requesting the download.
     * @param feed         The feed whose next page should be loaded.
     * @param loadAllPages True if any subsequent pages should also be loaded, false otherwise.
     */
    public static void loadNextPageOfFeed(final Context context, Feed feed, boolean loadAllPages) {
        if (feed.isPaged() && feed.getNextPageLink() != null) {
            int pageNr = feed.getPageNr() + 1;
            Feed nextFeed = new Feed(feed.getNextPageLink(), null, feed.getTitle() + "(" + pageNr + ")");
            nextFeed.setPageNr(pageNr);
            nextFeed.setPaged(true);
            nextFeed.setId(feed.getId());

            DownloadRequest.Builder builder = DownloadRequestCreator.create(nextFeed);
            builder.loadAllPages(loadAllPages);
            DownloadService.download(context, false, builder.build());
        } else {
            Log.e(TAG, "loadNextPageOfFeed: Feed was either not paged or contained no nextPageLink");
        }
    }

    public static void forceRefreshFeed(Context context, Feed feed, boolean initiatedByUser) {
        forceRefreshFeed(context, feed, false, initiatedByUser);
    }

    public static void forceRefreshCompleteFeed(final Context context, final Feed feed) {
        forceRefreshFeed(context, feed, true, true);
    }

    private static void forceRefreshFeed(Context context, Feed feed, boolean loadAllPages, boolean initiatedByUser) {
        DownloadRequest.Builder builder = DownloadRequestCreator.create(feed);
        builder.withInitiatedByUser(initiatedByUser);
        builder.setForce(true);
        builder.loadAllPages(loadAllPages);
        DownloadService.download(context, false, builder.build());
    }

    /**
     * Notifies the database about a missing FeedMedia file. This method will correct the FeedMedia object's
     * values in the DB and send a FeedItemEvent.
     */
    public static void notifyMissingFeedMediaFile(final Context context, final FeedMedia media) {
        Log.i(TAG, "The feedmanager was notified about a missing episode. It will update its database now.");
        media.setDownloaded(false);
        media.setFile_url(null);
        DBWriter.setFeedMedia(media);
        EventBus.getDefault().post(FeedItemEvent.updated(media.getItem()));
//        EventBus.getDefault().post(new MessageEvent(context.getString(R.string.error_file_not_found)));
    }

    public static List<FeedItem> enqueueFeedItemsToDownload(final Context context,
                       List<FeedItem> items) throws InterruptedException, ExecutionException {
        List<FeedItem> itemsToEnqueue = new ArrayList<>();
        if (Prefs.enqueueDownloadedEpisodes()) {
            LongList queueIDList = DBReader.getQueueIDList();
            for (FeedItem item : items) {
                if (!queueIDList.contains(item.getId())) {
                    itemsToEnqueue.add(item);
                }
            }
            DBWriter.addQueueItem(context, false, itemsToEnqueue.toArray(new FeedItem[0])).get();
        }
        return itemsToEnqueue;
    }

    /**
     * Looks for non-downloaded episodes in the queue or list of unread items and request a download if
     * 1. Network is available
     * 2. The device is charging or the user allows auto download on battery
     * 3. There is free space in the episode cache
     * This method is executed on an internal single thread executor.
     *
     * @param context  Used for accessing the DB.
     * @return A Future that can be used for waiting for the methods completion.
     */
    public static Future<?> autodownloadUndownloadedItems(final Context context) {
        Log.d(TAG, "autodownloadUndownloadedItems");
        return autodownloadExec.submit(downloadAlgorithm.autoDownloadUndownloadedItems(context));
    }

    /**
     * For testing purpose only.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    public static void setDownloadAlgorithm(AutomaticDownloadAlgorithm newDownloadAlgorithm) {
        downloadAlgorithm = newDownloadAlgorithm;
    }

    /**
     * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller
     * 'playbackCompletionDate'-value will be deleted first.
     * <p/>
     * This method should NOT be executed on the GUI thread.
     *
     * @param context Used for accessing the DB.
     */
    public static void performAutoCleanup(final Context context) {
        Prefs.getEpisodeCleanupAlgorithm().performCleanup(context);
    }

    private static Feed searchFeedByIdentifyingValueOrID(Feed feed) {
        if (feed.getId() != 0) {
            return DBReader.getFeed(feed.getId());
        } else {
            List<Feed> feeds = DBReader.getFeedList();
            for (Feed f : feeds) {
                if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) {
                    f.setItems(DBReader.getFeedItemList(f));
                    return f;
                }
            }
        }
        return null;
    }

    /**
     * Get a FeedItem by its identifying value.
     */
    private static FeedItem searchFeedItemByIdentifyingValue(List<FeedItem> items, FeedItem searchItem) {
        for (FeedItem item : items) {
            if (TextUtils.equals(item.getIdentifyingValue(), searchItem.getIdentifyingValue())) {
                return item;
            }
        }
        return null;
    }

    /**
     * Guess if one of the items could actually mean the searched item, even if it uses another identifying value.
     * This is to work around podcasters breaking their GUIDs.
     */
    private static FeedItem searchFeedItemGuessDuplicate(List<FeedItem> items, FeedItem searchItem) {
        for (FeedItem item : items) {
            if (FeedItemDuplicateGuesser.seemDuplicates(item, searchItem)) {
                return item;
            }
        }
        return null;
    }

    /**
     * Adds new Feeds to the database or updates the old versions if they already exists. If another Feed with the same
     * identifying value already exists, this method will add new FeedItems from the new Feed to the existing Feed.
     * These FeedItems will be marked as unread with the exception of the most recent FeedItem.
     * <p/>
     * This method can update multiple feeds at once. Submitting a feed twice in the same method call can result in undefined behavior.
     * <p/>
     * This method should NOT be executed on the GUI thread.
     *
     * @param context Used for accessing the DB.
     * @param newFeed The new Feed object.
     * @param removeUnlistedItems The item list in the new Feed object is considered to be exhaustive.
     *                            I.e. items are removed from the database if they are not in this item list.
     * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise.
     */
    public static synchronized Feed updateFeed(Context context, Feed newFeed, boolean removeUnlistedItems) {
        Feed resultFeed;
        List<FeedItem> unlistedItems = new ArrayList<>();

        Db adapter = Db.getInstance();
        adapter.open();

        // Look up feed in the feedslist
        final Feed savedFeed = searchFeedByIdentifyingValueOrID(newFeed);
        if (savedFeed == null) {
            Log.d(TAG, "Found no existing Feed with title "
                            + newFeed.getTitle() + ". Adding as new one.");

            // Add a new Feed
            // all new feeds will have the most recent item marked as unplayed
            FeedItem mostRecent = newFeed.getMostRecentItem();
            if (mostRecent != null) {
                mostRecent.setNew();
            }

            resultFeed = newFeed;
        } else {
            Log.d(TAG, "Feed with title " + newFeed.getTitle()
                        + " already exists. Syncing new with existing one.");

            Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator());

            if (newFeed.getPageNr() == savedFeed.getPageNr()) {
                if (savedFeed.compareWithOther(newFeed)) {
                    Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes");
                    savedFeed.updateFromOther(newFeed);
                }
            } else {
                Log.d(TAG, "New feed has a higher page number.");
                savedFeed.setNextPageLink(newFeed.getNextPageLink());
            }
            if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) {
                Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences");
                savedFeed.getPreferences().updateFromOther(newFeed.getPreferences());
            }

            // get the most recent date now, before we start changing the list
            FeedItem priorMostRecent = savedFeed.getMostRecentItem();
            Date priorMostRecentDate = null;
            if (priorMostRecent != null) {
                priorMostRecentDate = priorMostRecent.getPubDate();
            }

            // Look for new or updated Items
            for (int idx = 0; idx < newFeed.getItems().size(); idx++) {
                final FeedItem item = newFeed.getItems().get(idx);

                FeedItem possibleDuplicate = searchFeedItemGuessDuplicate(newFeed.getItems(), item);
                if (!newFeed.isLocalFeed() && possibleDuplicate != null && item != possibleDuplicate) {
                    // Canonical episode is the first one returned (usually oldest)
                    DBWriter.addDownloadStatus(new DownloadStatus(savedFeed,
                            item.getTitle(), DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
                            "The podcast host appears to have added the same episode twice. "
                                    + "still refreshed the feed and attempted to repair it."
                                    + "\n\nOriginal episode:\n" + duplicateEpisodeDetails(item)
                                    + "\n\nSecond episode that is also in the feed:\n"
                                    + duplicateEpisodeDetails(possibleDuplicate), false));
                    continue;
                }

                FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed.getItems(), item);
                if (!newFeed.isLocalFeed() && oldItem == null) {
                    oldItem = searchFeedItemGuessDuplicate(savedFeed.getItems(), item);
                    if (oldItem != null) {
                        Log.d(TAG, "Repaired duplicate: " + oldItem + ", " + item);
                        DBWriter.addDownloadStatus(new DownloadStatus(savedFeed,
                                item.getTitle(), DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
                                "The podcast host changed the ID of an existing episode instead of just "
                                        + "updating the episode itself. still refreshed the feed and "
                                        + "attempted to repair it."
                                        + "\n\nOriginal episode:\n" + duplicateEpisodeDetails(oldItem)
                                        + "\n\nNow the feed contains:\n" + duplicateEpisodeDetails(item), false));
                        oldItem.setItemIdentifier(item.getItemIdentifier());

                        if (oldItem.isPlayed() && oldItem.getMedia() != null) {
                            EpisodeAction action = new EpisodeAction.Builder(oldItem, EpisodeAction.PLAY)
                                    .currentTimestamp()
                                    .started(oldItem.getMedia().getDuration() / 1000)
                                    .position(oldItem.getMedia().getDuration() / 1000)
                                    .total(oldItem.getMedia().getDuration() / 1000)
                                    .build();
                            SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
                        }
                    }
                }

                if (oldItem != null) {
                    oldItem.updateFromOther(item);
                } else {
                    Log.d(TAG, "Found new item: " + item.getTitle());
                    item.setFeed(savedFeed);

                    if (idx >= savedFeed.getItems().size()) {
                        savedFeed.getItems().add(item);
                    } else {
                        savedFeed.getItems().add(idx, item);
                    }

                    // only mark the item new if it was published after or at the same time
                    // as the most recent item
                    // (if the most recent date is null then we can assume there are no items
                    // and this is the first, hence 'new')
                    // New items that do not have a pubDate set are always marked as new
                    if (item.getPubDate() == null || priorMostRecentDate == null
                            || priorMostRecentDate.before(item.getPubDate())
                            || priorMostRecentDate.equals(item.getPubDate())) {
                        Log.d(TAG, "Marking item published on " + item.getPubDate()
                                + " new, prior most recent date = " + priorMostRecentDate);
                        item.setNew();
                    }
                }
            }

            // identify items to be removed
            if (removeUnlistedItems) {
                Iterator<FeedItem> it = savedFeed.getItems().iterator();
                while (it.hasNext()) {
                    FeedItem feedItem = it.next();
                    if (searchFeedItemByIdentifyingValue(newFeed.getItems(), feedItem) == null) {
                        unlistedItems.add(feedItem);
                        it.remove();
                    }
                }
            }

            // update attributes
            savedFeed.setLastUpdate(newFeed.getLastUpdate());
            savedFeed.setType(newFeed.getType());
            savedFeed.setLastUpdateFailed(false);

            resultFeed = savedFeed;
        }

        try {
            if (savedFeed == null) {
                DBWriter.addNewFeed(context, newFeed).get();
                // Update with default values that are set in database
                resultFeed = searchFeedByIdentifyingValueOrID(newFeed);
            } else {
                DBWriter.setCompleteFeed(savedFeed).get();
            }
            if (removeUnlistedItems) {
                DBWriter.deleteFeedItems(context, unlistedItems).get();
            }
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        adapter.close();

        if (savedFeed != null) {
            EventBus.getDefault().post(new FeedListUpdateEvent(savedFeed));
        } else {
            EventBus.getDefault().post(new FeedListUpdateEvent(Collections.emptyList()));
        }

        return resultFeed;
    }

    private static String duplicateEpisodeDetails(FeedItem item) {
        return "Title: " + item.getTitle()
                + "\nID: " + item.getItemIdentifier()
                + ((item.getMedia() == null) ? "" : "\nURL: " + item.getMedia().getDownload_url());
    }

    /**
     * Searches the FeedItems of a specific Feed for a given string.
     *
     * @param feedID  The id of the feed whose items should be searched.
     * @param query   The search string.
     * @return A FutureTask object that executes the search request
     *         and returns the search result as a List of FeedItems.
     */
    public static FutureTask<List<FeedItem>> searchFeedItems(final long feedID, final String query) {
        return new FutureTask<>(new QueryTask<List<FeedItem>>() {
            @Override
            public void execute(Db adapter) {
                Cursor searchResult = adapter.searchItems(feedID, query);
                List<FeedItem> items = DBReader.extractItemlistFromCursor(searchResult);
                DBReader.loadAdditionalFeedItemListData(items);
                setResult(items);
                searchResult.close();
            }
        });
    }

    public static FutureTask<List<Feed>> searchFeeds(final String query) {
        return new FutureTask<>(new QueryTask<List<Feed>>() {
            @Override
            public void execute(Db adapter) {
                Cursor cursor = adapter.searchFeeds(query);
                List<Feed> items = new ArrayList<>();
                if (cursor.moveToFirst()) {
                    do {
                        items.add(FeedCursorMapper.convert(cursor));
                    } while (cursor.moveToNext());
                }
                setResult(items);
                cursor.close();
            }
        });
    }

    /**
     * A runnable which should be used for database queries. The onCompletion
     * method is executed on the database executor to handle Cursors correctly.
     * This class automatically creates a PodDBAdapter object and closes it when
     * it is no longer in use.
     */
    abstract static class QueryTask<T> implements Callable<T> {
        private T result;

        public QueryTask() {
        }

        @Override
        public T call() throws Exception {
            Db adapter = Db.getInstance();
            adapter.open();
            execute(adapter);
            adapter.close();
            return result;
        }

        public abstract void execute(Db adapter);

        void setResult(T result) {
            this.result = result;
        }
    }
}
