package ac.mdiq.podcini.net.sync

import ac.mdiq.podcini.R
import ac.mdiq.podcini.config.CHANNEL_ID
import ac.mdiq.podcini.gears.gearbox
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.sync.LockingAsyncExecutor.executeLockedAsync
import ac.mdiq.podcini.net.sync.SynchronizationCredentials.hosturl
import ac.mdiq.podcini.net.sync.SynchronizationCredentials.password
import ac.mdiq.podcini.net.sync.SynchronizationCredentials.username
import ac.mdiq.podcini.net.sync.SynchronizationProviderViewData.Companion.fromIdentifier
import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.model.ISyncService
import ac.mdiq.podcini.net.sync.model.SyncServiceException
import ac.mdiq.podcini.net.sync.nextcloud.NextcloudSyncService
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueStorage
import ac.mdiq.podcini.net.utils.NetworkUtils.containsUrl
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileFor
import ac.mdiq.podcini.net.utils.NetworkUtils.setAllowMobileFor
import ac.mdiq.podcini.playback.base.InTheatre.actQueue
import ac.mdiq.podcini.preferences.screens.MobileUpdateOptions
import ac.mdiq.podcini.storage.database.deleteFeed
import ac.mdiq.podcini.storage.database.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.getEpisodes
import ac.mdiq.podcini.storage.database.getFeed
import ac.mdiq.podcini.storage.database.getFeedList
import ac.mdiq.podcini.storage.database.getFeedListDownloadUrls
import ac.mdiq.podcini.storage.database.removeFromQueue
import ac.mdiq.podcini.storage.database.runOnIOScope
import ac.mdiq.podcini.storage.database.updateFeedFull
import ac.mdiq.podcini.storage.database.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.specs.EpisodeFilter
import ac.mdiq.podcini.storage.specs.EpisodeSortOrder
import ac.mdiq.podcini.utils.EventFlow
import ac.mdiq.podcini.utils.FlowEvent
import ac.mdiq.podcini.utils.Logd
import ac.mdiq.podcini.utils.Loge
import ac.mdiq.podcini.utils.Logs
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.collection.ArrayMap
import androidx.core.app.NotificationCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints.Builder
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import org.apache.commons.lang3.StringUtils
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit

open class SyncService(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    val TAG = this::class.simpleName ?: "Anonymous"

    protected val synchronizationQueueStorage = SynchronizationQueueStorage(context)

     override suspend fun doWork(): Result {
        Logd(TAG, "doWork() called")
        val activeSyncProvider = getActiveSyncProvider() ?: return Result.failure()
        Logd(TAG, "doWork() got syn provider")

        SynchronizationSettings.updateLastSynchronizationAttempt()
        setCurrentlyActive(true)
        try {
            activeSyncProvider.login()
            syncSubscriptions(activeSyncProvider)
//            runBlocking { waitForDownloadServiceCompleted() }
            waitForDownloadServiceCompleted()

//            sync Episode changes
//            syncEpisodeActions(activeSyncProvider)
            var (lastSync, newTimeStamp) = getEpisodeActions(activeSyncProvider)
            // upload local actions
            newTimeStamp = pushEpisodeActions(activeSyncProvider, lastSync, newTimeStamp)
            SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp)

            activeSyncProvider.logout()
            clearErrorNotifications()
            EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_success))
            SynchronizationSettings.setLastSynchronizationAttemptSuccess(true)
            return Result.success()
        } catch (e: Exception) {
            EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_error))
            SynchronizationSettings.setLastSynchronizationAttemptSuccess(false)
            if (e is SyncServiceException) {
                // Do not spam users with notification and retry before notifying
                if (runAttemptCount % 3 == 2) {
                    Logs(TAG, e)
                    updateErrorNotification(e)
                }
                return Result.retry()
            } else {
                Logs(TAG, e)
                updateErrorNotification(e)
                return Result.failure()
            }
        } finally { setCurrentlyActive(false) }
    }

    @Throws(SyncServiceException::class)
    private fun syncSubscriptions(syncServiceImpl: ISyncService) {
        Logd(TAG, "syncSubscriptions called")
        val lastSync = SynchronizationSettings.lastSubscriptionSynchronizationTimestamp
        EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_subscriptions))
        val localSubscriptions: List<String> = getFeedListDownloadUrls()
        val subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync)
        var newTimeStamp = subscriptionChanges?.timestamp?:0L

        val queuedRemovedFeeds: MutableList<String> = synchronizationQueueStorage.queuedRemovedFeeds
        var queuedAddedFeeds: List<String> = synchronizationQueueStorage.queuedAddedFeeds

        Logd(TAG, "Downloaded subscription changes: $subscriptionChanges")
        if (subscriptionChanges != null) {
            for (downloadUrl in subscriptionChanges.added) {
                if (!downloadUrl.startsWith("http")) { // Also matches https
                    Logd(TAG, "Skipping url: $downloadUrl")
                    continue
                }
                if (!containsUrl(localSubscriptions, downloadUrl) && !queuedRemovedFeeds.contains(downloadUrl)) {
                    val feed = Feed(downloadUrl, null, "Unknown podcast")
                    feed.episodes.clear()
                    updateFeedFull(applicationContext, feed, removeUnlistedItems = false)
                    val f = getFeed(feed.id)
                    if (f != null) gearbox.feedUpdater(listOf(f)).startRefresh(applicationContext)
                }
            }

            // remove subscription if not just subscribed (again)
            for (downloadUrl in subscriptionChanges.removed) {
                if (!queuedAddedFeeds.contains(downloadUrl)) removeFeedWithDownloadUrl(applicationContext, downloadUrl)
            }

            if (lastSync == 0L) {
                Logd(TAG, "First sync. Adding all local subscriptions.")
                queuedAddedFeeds = localSubscriptions.toMutableList()
                queuedAddedFeeds.removeAll(subscriptionChanges.added)
                queuedRemovedFeeds.removeAll(subscriptionChanges.removed)
            }
        }

        if (queuedAddedFeeds.isNotEmpty() || queuedRemovedFeeds.isNotEmpty()) {
            Logd(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", "))
            Logd(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", "))

            LockingAsyncExecutor.lock.lock()
            try {
                val uploadResponse = syncServiceImpl.uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds)
                synchronizationQueueStorage.clearFeedQueues()
                if (uploadResponse != null) newTimeStamp = uploadResponse.timestamp
            } finally { LockingAsyncExecutor.lock.unlock() }
        }
        SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp)
    }

    private fun removeFeedWithDownloadUrl(context: Context, downloadUrl: String) {
        Logd(TAG, "removeFeedWithDownloadUrl called")
        var feedID: Long? = null
        val feeds = getFeedList()
        for (f in feeds) {
            val url = f.downloadUrl
            if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) feedID = f.id
        }
        if (feedID != null) {
            try {
                runBlocking {
                    deleteFeed(context, feedID)
                    EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feedID))
                }
            } catch (e: InterruptedException) { Logs(TAG, e)
            } catch (e: ExecutionException) { Logs(TAG, e) }
        } else Loge(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: $downloadUrl")
    }

    private suspend fun waitForDownloadServiceCompleted() {
        Logd(TAG, "waitForDownloadServiceCompleted called")
        EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_wait_for_downloads))
        val event = EventFlow.stickyEvents
            .filter { it is FlowEvent.FeedUpdatingEvent }
            .map { it as FlowEvent.FeedUpdatingEvent }
            .firstOrNull { !it.isRunning }
        if (event == null || !event.isRunning) return
        EventFlow.stickyEvents
            .filter { it is FlowEvent.FeedUpdatingEvent }
            .map { it as FlowEvent.FeedUpdatingEvent }
            .first { !it.isRunning }
    }

    private fun getEpisodeActions(syncServiceImpl: ISyncService) : Pair<Long, Long> {
        val lastSync = SynchronizationSettings.lastEpisodeActionSynchronizationTimestamp
        EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_episodes_download))
        val getResponse = syncServiceImpl.getEpisodeActionChanges(lastSync)
        val newTimeStamp = getResponse?.timestamp?:0L
        val remoteActions = getResponse?.episodeActions?: listOf()
        processEpisodeActions(remoteActions)
        return Pair(lastSync, newTimeStamp)
    }

    open fun pushEpisodeActions(syncServiceImpl: ISyncService, lastSync: Long, newTimeStamp_: Long): Long {
        var newTimeStamp = newTimeStamp_
        EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_episodes_upload))
        val queuedEpisodeActions: MutableList<EpisodeAction> = synchronizationQueueStorage.queuedEpisodeActions
        if (lastSync == 0L) {
            EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_upload_played))
            val readItems = getEpisodes(EpisodeFilter(EpisodeFilter.States.played.name), EpisodeSortOrder.DATE_DESC)
            Logd(TAG, "First sync. Upload state for all " + readItems.size + " played episodes")
            for (item in readItems) {
                val played = EpisodeAction.Builder(item, EpisodeAction.PLAY)
                    .currentTimestamp()
                    .started(item.duration / 1000)
                    .position(item.position / 1000)
                    .total(item.duration / 1000)
                    .build()
                queuedEpisodeActions.add(played)
            }
        }
        if (queuedEpisodeActions.isNotEmpty()) {
            LockingAsyncExecutor.lock.lock()
            try {
                Logd(TAG, "Uploading ${queuedEpisodeActions.size} actions: ${StringUtils.join(queuedEpisodeActions, ", ")}")
                val postResponse = syncServiceImpl.uploadEpisodeActions(queuedEpisodeActions)
                newTimeStamp = postResponse?.timestamp?:0L
                Logd(TAG, "Upload episode response: $postResponse")
                synchronizationQueueStorage.clearEpisodeActionQueue()
            } finally { LockingAsyncExecutor.lock.unlock() }
        }
        return newTimeStamp
    }

     @Throws(SyncServiceException::class)
    private fun syncEpisodeActions(syncServiceImpl: ISyncService) {
        Logd(TAG, "syncEpisodeActions called")
        var (lastSync, newTimeStamp) = getEpisodeActions(syncServiceImpl)

        // upload local actions
        newTimeStamp = pushEpisodeActions(syncServiceImpl, lastSync, newTimeStamp)
        SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp)
    }

    open fun processEpisodeAction(action: EpisodeAction): Pair<Long, Episode>? {
        val guid = if (isValidGuid(action.guid)) action.guid else null
        val feedItem = getEpisodeByGuidOrUrl(guid, action.episode)
        if (feedItem == null) {
            Logd(TAG, "Unknown feed item: $action")
            return null
        }
        var idRemove: Long? = null
        feedItem.setPosition(action.position * 1000)
        if (feedItem.hasAlmostEnded()) {
            Logd(TAG, "Marking as played: $action")
            feedItem.setPlayed(true)
            feedItem.setPosition(0)
            idRemove = feedItem.id
        } else Logd(TAG, "Setting position: $action")

        return if (idRemove != null) Pair(idRemove, feedItem) else null
    }

     @Synchronized
    fun processEpisodeActions(remoteActions: List<EpisodeAction>) {
        Logd(TAG, "Processing " + remoteActions.size + " actions")
        if (remoteActions.isEmpty()) return

        val playActionsToUpdate = getRemoteActionsOverridingLocalActions(remoteActions, synchronizationQueueStorage.queuedEpisodeActions)
//        val queueToBeRemoved = mutableListOf<FeedItem>()
        val updatedItems: MutableList<Episode> = mutableListOf()
        for (action in playActionsToUpdate.values) {
            val result = processEpisodeAction(action) ?: continue
//            if (result.first != null) queueToBeRemoved.add(result.second)
            updatedItems.add(result.second)
        }

        runOnIOScope {
            removeFromQueue(actQueue, updatedItems)
            for (episode in updatedItems) upsert(episode) {}
//            EventFlow.postEvent(FlowEvent.EpisodeEvent(updatedItems))
        }
    }

    private fun clearErrorNotifications() {
        val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        nm.cancel(R.id.notification_gpodnet_sync_error)
        nm.cancel(R.id.notification_gpodnet_sync_autherror)
    }

    private fun gpodnetNotificationsEnabled(): Boolean {
        return true // for Android SDK 26 and above
    }

    private fun updateErrorNotification(exception: Exception) {
        Logd(TAG, "Posting sync error notification")
        val description = ("${applicationContext.getString(R.string.gpodnetsync_error_descr)}${exception.message}")

        if (!gpodnetNotificationsEnabled()) {
            Logd(TAG, "Skipping sync error notification because of user setting")
            return
        }
//        TODO:
//        if (EventBus.getDefault().hasSubscriberForEvent(FlowEvent.MessageEvent::class.java)) {
//            EventFlow.postEvent(FlowEvent.MessageEvent(description))
//            return
//        }

        val intent = applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName)
        val pendingIntent = PendingIntent.getActivity(applicationContext, R.id.pending_intent_sync_error, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
        val notification = NotificationCompat.Builder(applicationContext,
            CHANNEL_ID.sync_error.name)
            .setContentTitle(applicationContext.getString(R.string.gpodnetsync_error_title))
            .setContentText(description)
            .setStyle(NotificationCompat.BigTextStyle().bigText(description))
            .setContentIntent(pendingIntent)
            .setSmallIcon(R.drawable.ic_notification_sync_error)
            .setAutoCancel(true)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .build()
        val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        nm.notify(R.id.notification_gpodnet_sync_error, notification)
    }

    private fun getActiveSyncProvider(): ISyncService? {
        val selectedSyncProviderKey = SynchronizationSettings.selectedSyncProviderKey
        val selectedService = fromIdentifier(selectedSyncProviderKey?:"")
        if (selectedService == null) return null

        return when (selectedService) {
//            SynchronizationProviderViewData.GPODDER_NET -> GpodnetService(getHttpClient(), hosturl, deviceID?:"", username?:"", password?:"")
            SynchronizationProviderViewData.NEXTCLOUD_GPODDER -> NextcloudSyncService(getHttpClient(), hosturl, username?:"", password?:"")
        }
    }

    companion object {
        private val TAG = SyncService::class.simpleName ?: "Anonymous"

        private const val WORK_ID_SYNC = "SyncServiceWorkId"

        private var isCurrentlyActive = false
        internal fun setCurrentlyActive(active: Boolean) {
            isCurrentlyActive = active
        }
        private var isAllowMobileSync: Boolean
            get() = isAllowMobileFor(MobileUpdateOptions.sync.name)
            set(allow) {
                setAllowMobileFor(MobileUpdateOptions.sync.name, allow)
            }

        private fun getWorkRequest(): OneTimeWorkRequest.Builder {
            val constraints = Builder()
            if (isAllowMobileSync) constraints.setRequiredNetworkType(NetworkType.CONNECTED)
            else constraints.setRequiredNetworkType(NetworkType.UNMETERED)

            val builder: OneTimeWorkRequest.Builder = OneTimeWorkRequest.Builder(SyncService::class.java)
                .setConstraints(constraints.build())
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)

            if (isCurrentlyActive) {
                // Debounce: don't start sync again immediately after it was finished.
                builder.setInitialDelay(2L, TimeUnit.MINUTES)
            } else {
                // Give it some time, so other possible actions can be queued.
                builder.setInitialDelay(20L, TimeUnit.SECONDS)
                EventFlow.postStickyEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_started))
            }
            return builder
        }

        fun sync(context: Context) {
            val workRequest: OneTimeWorkRequest = getWorkRequest().build()
            WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest)
        }

        fun syncImmediately(context: Context) {
            val workRequest: OneTimeWorkRequest = getWorkRequest().setInitialDelay(0L, TimeUnit.SECONDS).build()
            WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest)
        }

        fun fullSync(context: Context) {
            executeLockedAsync {
                SynchronizationSettings.resetTimestamps()
                val workRequest: OneTimeWorkRequest = getWorkRequest().setInitialDelay(0L, TimeUnit.SECONDS).build()
                WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest)
            }
        }

        fun getRemoteActionsOverridingLocalActions(remoteActions: List<EpisodeAction>, queuedEpisodeActions: List<EpisodeAction>): Map<Pair<String, String>, EpisodeAction> {
            // make sure more recent local actions are not overwritten by older remote actions
            val remoteActionsThatOverrideLocalActions: MutableMap<Pair<String, String>, EpisodeAction> = ArrayMap()
            val localMostRecentPlayActions = createUniqueLocalMostRecentPlayActions(queuedEpisodeActions)
            for (remoteAction in remoteActions) {
                val key = Pair(remoteAction.podcast, remoteAction.episode)
                when (remoteAction.action) {
                    EpisodeAction.Action.NEW, EpisodeAction.Action.DOWNLOAD -> {}
                    EpisodeAction.Action.PLAY -> {
                        val localMostRecent = localMostRecentPlayActions[key]
                        if (secondActionOverridesFirstAction(remoteAction, localMostRecent)) break
                        val remoteMostRecentAction = remoteActionsThatOverrideLocalActions[key]
                        if (secondActionOverridesFirstAction(remoteAction, remoteMostRecentAction)) break
                        remoteActionsThatOverrideLocalActions[key] = remoteAction
                    }
                    EpisodeAction.Action.DELETE -> {}
                    else -> Loge(TAG, "Unknown remoteAction: $remoteAction")
                }
            }
            return remoteActionsThatOverrideLocalActions
        }

        private fun createUniqueLocalMostRecentPlayActions(queuedEpisodeActions: List<EpisodeAction>): Map<Pair<String, String>, EpisodeAction> {
            val localMostRecentPlayAction: MutableMap<Pair<String, String>, EpisodeAction> = ArrayMap()
            for (action in queuedEpisodeActions) {
                val key = Pair(action.podcast, action.episode)
                val mostRecent = localMostRecentPlayAction[key]
                when {
                    mostRecent?.timestamp == null -> localMostRecentPlayAction[key] = action
                    mostRecent.timestamp.before(action.timestamp) -> localMostRecentPlayAction[key] = action
                }
            }
            return localMostRecentPlayAction
        }

        private fun secondActionOverridesFirstAction(firstAction: EpisodeAction, secondAction: EpisodeAction?): Boolean {
            return secondAction?.timestamp != null && (firstAction.timestamp == null || secondAction.timestamp.after(firstAction.timestamp))
        }

        fun isValidGuid(guid: String?): Boolean {
            return (guid != null && guid.trim { it <= ' ' }.isNotEmpty() && guid != "null")
        }
    }
}
