package invalid.lena.rsend

import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext

// SyncWorker runs one full sync as a foreground job: it pushes every configured
// folder with rsync and records the outcome in the log. It is used for both the
// manual "Sync now" action and the periodic schedule.
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {

    companion object {
        const val KEY_FOLDER = "folder"
        const val KEY_INDEX = "i"
        const val KEY_TOTAL = "n"

        private val syncMutex = Mutex()
    }

    override suspend fun doWork(): Result = syncMutex.withLock {
        val ctx = applicationContext
        try {
            withContext(Dispatchers.IO) {
                val cfg = Config.load(ctx)
                // A refused run must be as visible as a failed one. These return
                // before the loop that would otherwise record the outcome, so
                // without this the dashboard keeps showing the last success while
                // nothing has been backed up since.
                if (!cfg.syncReady(Keys.exists(ctx))) {
                    return@withContext refuse(ctx, "key, folders, or a pinned remote missing")
                }
                // All-files access is the only storage permission rsend declares.
                // Without it configured storage can be unreadable. Refuse the run
                // before rsync sees a partial view of the source tree.
                if (!Environment.isExternalStorageManager()) {
                    return@withContext refuse(ctx, "all-files access not granted")
                }

                val log = SyncLog(ctx)
                log.rotateIfBig()
                log.line("sync started (${cfg.folders.size} folders)")
                // A foreground service lets long syncs survive, but Android 12+ forbids
                // starting one when the periodic job fires in the background. Try it, and
                // fall back to a plain background job when it is not allowed.
                try {
                    setForeground(foregroundInfo())
                } catch (e: CancellationException) {
                    throw e
                } catch (e: Exception) {
                    // Worth shouting about: a background job is subject to the
                    // execution limit, so a large first sync can be cut short every
                    // period. rsync resumes, but the user should know why progress
                    // is slow and that the battery exemption fixes it.
                    log.line("WARNING: foreground service refused (${e.javaClass.simpleName}: ${e.message})")
                    log.line("WARNING: running as a background job, which the system may stop early;")
                    log.line("WARNING: grant Battery > unrestricted so long syncs can finish")
                }

                var ok = true
                val total = cfg.folders.size
                // Folder labels that failed, so the dashboard identifies the work
                // that did not complete instead of only saying the run failed.
                val failed = LinkedHashSet<String>()
                // Remotes that turned out to be unreachable during this run. A
                // dropped connect costs the full dial timeout, so pay it once per
                // remote instead of once per folder pointing at it.
                val unreachable = HashSet<String>()
                // Total removed on the server across the run.
                var deleted = 0L
                for ((i, f) in cfg.folders.withIndex()) {
                    val label = f.name.ifEmpty { f.local }
                    // Re-check per folder: a run that began on wifi can lose it and
                    // would otherwise push the rest of a photo library over mobile
                    // data. Stop rather than carry on against the user's setting.
                    if (cfg.schedule.wifiOnly && metered(ctx)) {
                        log.line("stopping: network became metered and the schedule is unmetered only")
                        ok = false
                        failed.add("metered network")
                        break
                    }
                    // Publish progress the dashboard observes (current folder, i of n).
                    setProgress(workDataOf(KEY_FOLDER to label.take(256), KEY_INDEX to i + 1, KEY_TOTAL to total))
                    log.line("=== $label -> ${f.remoteName} ===")
                    // Config parsing guarantees the remote exists; being pinned is
                    // a separate thing the user can still be part-way through.
                    val remote = checkNotNull(cfg.remote(f.remoteName))
                    val code = if (!remote.pinned()) {
                        log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection")
                        1
                    } else if (unreachable.contains(f.remoteName)) {
                        log.line("error: remote \"${f.remoteName}\" was unreachable earlier this run, skipped")
                        1
                    } else if (!FolderRules.sourceReadable(f.local)) {
                        log.line("error: local source \"${f.local}\" is not a readable directory")
                        1
                    } else try {
                        val r = RsyncRunner.runFolder(ctx, remote, f, log)
                        if (r.unreachable) unreachable.add(f.remoteName)
                        deleted += r.deleted
                        r.code
                    } catch (e: CancellationException) {
                        throw e
                    } catch (e: Exception) {
                        log.line("error: ${e.javaClass.simpleName}: ${e.message}")
                        1
                    }
                    log.line("exit=$code")
                    if (!RsyncRunner.succeeded(code, f.delete)) {
                        ok = false
                        failed.add(label)
                    }
                }
                log.line("sync finished ok=$ok")
                // A mirror may legitimately empty its destination, so deletions are
                // reported rather than prevented. Saying so on the dashboard, and
                // notifying when a run deleted more than it sent, is what turns a
                // surprising mass deletion into something noticed the same day.
                val summary = if (deleted > 0) " ($deleted deleted)" else ""
                LastSync.set(ctx, if (ok) "ok$summary" else "FAILED: ${failed.joinToString(", ")}$summary")
                if (!ok) {
                    notifyError(ctx)
                } else if (deleted > 0) {
                    notifyDeleted(ctx, deleted)
                }
                if (ok) Result.success() else Result.failure()
            }
        } catch (e: CancellationException) {
            // Cancellation can arrive at any suspension point, including
            // between folders or while publishing progress. Record it once at
            // the worker boundary instead of only around the rsync process.
            try {
                SyncLog(ctx).line("cancelled")
                LastSync.set(ctx, "interrupted")
            } catch (recordError: Exception) {
                e.addSuppressed(recordError)
            }
            throw e
        }
    }

    private fun notifyError(ctx: Context) {
        val n = NotificationCompat.Builder(ctx, App.CHANNEL)
            .setContentTitle("rsend")
            .setContentText("Sync failed. Open the app and check the log.")
            .setSmallIcon(R.drawable.ic_notification)
            .setAutoCancel(true)
            .build()
        try {
            NotificationManagerCompat.from(ctx).notify(2, n)
        } catch (_: SecurityException) {
            // POST_NOTIFICATIONS not granted; the log still records the failure.
        }
    }

    // notifyDeleted reports a successful mirror that removed files on the
    // server. Mirror semantics make that correct, but a user who did not mean
    // to empty a folder should hear about it while the local copy may still
    // exist, not months later.
    private fun notifyDeleted(ctx: Context, deleted: Long) {
        val n = NotificationCompat.Builder(ctx, App.CHANNEL)
            .setContentTitle("rsend")
            .setContentText("Sync ok. Mirror deleted $deleted item(s) on the server.")
            .setSmallIcon(R.drawable.ic_notification)
            .setAutoCancel(true)
            .build()
        try {
            NotificationManagerCompat.from(ctx).notify(3, n)
        } catch (_: SecurityException) {
            // POST_NOTIFICATIONS not granted; the log still records the count.
        }
    }

    // refuse records a run that never started, so the dashboard and the
    // notification say so instead of leaving the last success on screen.
    private fun refuse(ctx: Context, why: String): Result {
        SyncLog(ctx).line("sync skipped: $why")
        LastSync.set(ctx, "SKIPPED: $why")
        notifyError(ctx)
        return Result.failure()
    }

    // metered reports whether the current network costs money, so a run can
    // stop when the user asked for unmetered only. The periodic job has a
    // WorkManager constraint, but a manual run has none by design, and either
    // can lose wifi part way through a long sync.
    private fun metered(ctx: Context): Boolean =
        ctx.getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered

    private fun foregroundInfo(): ForegroundInfo {
        val n: Notification = NotificationCompat.Builder(applicationContext, App.CHANNEL)
            .setContentTitle("rsend")
            .setContentText("Syncing")
            .setSmallIcon(R.drawable.ic_notification)
            .setOngoing(true)
            .build()
        return ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
    }
}
