package com.syncflow

import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.syncflow.data.db.SyncDatabase
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.data.providers.CloudProvider
import com.syncflow.data.providers.nextcloud.NextcloudProvider
import com.syncflow.domain.sync.LocalAccessor
import com.syncflow.domain.model.*
import com.syncflow.domain.sync.SyncEngine
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.Instant

/**
 * Full-matrix end-to-end test of the REAL SyncEngine against a live Nextcloud, on the device:
 * in-memory Room DB, a real local folder per test, and the real NextcloudProvider over TLS.
 * Covers every direction, every delete behavior, updates, nested/recursive, filters, and conflicts.
 *
 * Creds via instrumentation args: -e ncUrl ... -e ncUser ... -e ncPass ...
 */
@RunWith(AndroidJUnit4::class)
class FullSyncEngineTest {

    private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
    private val args = InstrumentationRegistry.getArguments()
    private val url get() = args.getString("ncUrl")
    private val user get() = args.getString("ncUser")
    private val pass get() = args.getString("ncPass")

    private lateinit var db: SyncDatabase
    private lateinit var engine: SyncEngine
    private lateinit var provider: NextcloudProvider
    private var accountId = 0L
    private val remoteRoot = "SyncFlowFull_${System.currentTimeMillis()}"
    private val localDirs = mutableListOf<File>()

    @Before fun setUp() = runBlocking {
        assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
        db = Room.inMemoryDatabaseBuilder(ctx, SyncDatabase::class.java).build()
        val account = CloudAccount(0, "IT", user, ProviderType.NEXTCLOUD,
            """{"username":"$user","password":"$pass"}""", url, null)
        accountId = db.cloudAccountDao().insert(
            CloudAccountEntity(0, account.displayName, account.email, account.providerType,
                account.credentialJson, account.serverUrl, account.port))
        provider = NextcloudProvider(account)
        engine = SyncEngine(db.syncPairDao(), db.syncFileStateDao(), db.syncConflictDao(), db.syncEventDao(), ctx)
        provider.createDirectory(remoteRoot).getOrThrow()
    }

    @After fun tearDown() = runBlocking {
        runCatching { provider.deleteFile(remoteRoot) }
        localDirs.forEach { it.deleteRecursively() }
        if (::db.isInitialized) db.close()
    }

    // ── helpers ──────────────────────────────────────────────────────────────

    private suspend fun newPair(
        name: String,
        dir: SyncDirection = SyncDirection.TWO_WAY,
        delete: DeleteBehavior = DeleteBehavior.MIRROR,
        conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
        recursive: Boolean = true,
        excludeExtensions: String = "",
        includeExtensions: String = "",
        excludePatterns: String = "",
        skipHidden: Boolean = false,
        minKb: Long = 0L,
        maxKb: Long = 0L,
    ): Triple<SyncPair, File, String> {
        val local = File(ctx.cacheDir, "synctest_${name}_${System.currentTimeMillis()}").apply { mkdirs() }
        localDirs += local
        val remote = "$remoteRoot/$name"
        provider.createDirectory(remote).getOrThrow()
        val id = db.syncPairDao().insert(SyncPairEntity(
            id = 0, name = name, localPath = local.absolutePath, remotePath = remote, accountId = accountId,
            syncDirection = dir, conflictStrategy = conflict, deleteBehavior = delete, recursive = recursive,
            scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 30, scheduleDailyTime = null, scheduleWeekdays = 0,
            wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
            excludePatterns = excludePatterns, includeExtensions = includeExtensions, excludeExtensions = excludeExtensions,
            skipHiddenFiles = skipHidden, minFileSizeKb = minKb, maxFileSizeKb = maxKb,
            notifyOnComplete = false, notifyOnError = false,
            isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
        ))
        return Triple(db.syncPairDao().getById(id)!!.toDomain(), local, remote)
    }

    private suspend fun sync(pair: SyncPair) = engine.sync(pair, provider)
    private fun write(dir: File, rel: String, content: String) =
        File(dir, rel).apply { parentFile?.mkdirs() }.writeText(content)
    private suspend fun remoteNames(remote: String) =
        provider.listFiles(remote).getOrThrow().map { it.name }
    private suspend fun remoteText(path: String): String {
        val out = ByteArrayOutputStream(); provider.downloadFile(path, out).getOrThrow(); return out.toString("UTF-8")
    }
    private suspend fun putRemote(remote: String, name: String, content: String) {
        val b = content.toByteArray()
        provider.uploadFile(ByteArrayInputStream(b), "$remote/$name", b.size.toLong()).getOrThrow()
    }

    // ── 1. Upload-only backup: delete on phone keeps the cloud copy (KEEP) ────
    @Test fun uploadOnly_keep_localDeleteKeepsCloud() = runBlocking {
        val (pair, local, remote) = newPair("ul_keep", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        write(local, "a.txt", "hello")
        assertEquals(1, sync(pair).uploaded)
        assertTrue("a.txt" in remoteNames(remote))
        File(local, "a.txt").delete()
        val r = sync(pair)
        assertEquals("KEEP must not delete remotely", 0, r.deleted)
        assertTrue("cloud copy must survive", "a.txt" in remoteNames(remote))
    }

    // ── 2. Upload-only + MIRROR: local delete removes the cloud copy ──────────
    @Test fun uploadOnly_mirror_localDeleteRemovesCloud() = runBlocking {
        val (pair, local, remote) = newPair("ul_mirror", SyncDirection.UPLOAD_ONLY, DeleteBehavior.MIRROR)
        write(local, "a.txt", "hello"); assertEquals(1, sync(pair).uploaded)
        File(local, "a.txt").delete()
        assertEquals(1, sync(pair).deleted)
        assertFalse("a.txt" in remoteNames(remote))
    }

    // ── 3. Upload-only + ARCHIVE: deleted file moved to _Deleted/ ─────────────
    @Test fun uploadOnly_archive_movesToDeleted() = runBlocking {
        val (pair, local, remote) = newPair("ul_archive", SyncDirection.UPLOAD_ONLY, DeleteBehavior.ARCHIVE)
        write(local, "a.txt", "keepme"); sync(pair)
        File(local, "a.txt").delete(); sync(pair)
        assertFalse("a.txt" in remoteNames(remote))
        assertTrue("archived copy expected", "a.txt" in remoteNames("$remote/_Deleted"))
    }

    // ── 4. Two-way initial sync: each side gets the other's files ─────────────
    @Test fun twoWay_initial_mergesBothSides() = runBlocking {
        val (pair, local, remote) = newPair("tw_init", SyncDirection.TWO_WAY)
        write(local, "local.txt", "L")
        putRemote(remote, "remote.txt", "R")
        val r = sync(pair)
        assertEquals(1, r.uploaded); assertEquals(1, r.downloaded)
        assertTrue(File(local, "remote.txt").exists())
        assertTrue("local.txt" in remoteNames(remote) && "remote.txt" in remoteNames(remote))
    }

    // ── 5. Two-way: a local edit propagates to the remote ─────────────────────
    @Test fun twoWay_localEdit_updatesRemote() = runBlocking {
        val (pair, local, remote) = newPair("tw_edit", SyncDirection.TWO_WAY)
        write(local, "f.txt", "v1"); sync(pair)
        Thread.sleep(1100) // cross the 1s mtime resolution
        write(local, "f.txt", "v2-updated"); val r = sync(pair)
        assertEquals(1, r.uploaded)
        assertEquals("v2-updated", remoteText("$remote/f.txt"))
    }

    // ── 6. Two-way + MIRROR: deleting locally removes it remotely ─────────────
    @Test fun twoWay_mirror_localDeletePropagates() = runBlocking {
        val (pair, local, remote) = newPair("tw_mirror", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
        write(local, "f.txt", "x"); sync(pair)
        File(local, "f.txt").delete()
        assertEquals(1, sync(pair).deleted)
        assertFalse("f.txt" in remoteNames(remote))
    }

    // ── 7. Download-only: pulls remote, never uploads local-only files ────────
    @Test fun downloadOnly_pullsRemoteIgnoresLocal() = runBlocking {
        val (pair, local, remote) = newPair("dl_only", SyncDirection.DOWNLOAD_ONLY)
        putRemote(remote, "cloud.txt", "from-cloud")
        write(local, "phoneonly.txt", "P")
        val r = sync(pair)
        assertEquals(1, r.downloaded); assertEquals(0, r.uploaded)
        assertEquals("from-cloud", File(local, "cloud.txt").readText())
        assertFalse("local-only file must NOT upload", "phoneonly.txt" in remoteNames(remote))
    }

    // ── 8. Recursive: nested directory structure is preserved ─────────────────
    @Test fun recursive_uploadsNestedTree() = runBlocking {
        val (pair, local, remote) = newPair("rec_on", SyncDirection.UPLOAD_ONLY, recursive = true)
        write(local, "sub/deep/n.txt", "nested")
        assertEquals(1, sync(pair).uploaded)
        assertTrue("n.txt" in remoteNames("$remote/sub/deep"))
    }

    // ── 9. recursive=false: subfolders are skipped ────────────────────────────
    @Test fun nonRecursive_skipsSubfolders() = runBlocking {
        val (pair, local, remote) = newPair("rec_off", SyncDirection.UPLOAD_ONLY, recursive = false)
        write(local, "top.txt", "T")
        write(local, "sub/deep.txt", "D")
        assertEquals(1, sync(pair).uploaded)
        assertTrue("top.txt" in remoteNames(remote))
        assertTrue("subfolder must be skipped", remoteNames(remote).none { it == "sub" })
    }

    // ── 10. Filters: excluded extension + hidden file are not uploaded ─────────
    @Test fun filters_excludeExtensionAndHidden() = runBlocking {
        val (pair, local, remote) = newPair("filters", SyncDirection.UPLOAD_ONLY,
            excludeExtensions = "tmp", skipHidden = true)
        write(local, "keep.txt", "k")
        write(local, "skip.tmp", "s")
        write(local, ".hidden", "h")
        sync(pair)
        val names = remoteNames(remote)
        assertTrue("keep.txt" in names)
        assertFalse("skip.tmp" in names)
        assertFalse(".hidden" in names)
    }

    // ── 11. Conflict: both sides changed (ASK) → conflict recorded, no clobber ─
    @Test fun twoWay_bothChanged_recordsConflict() = runBlocking {
        val (pair, local, remote) = newPair("conflict", SyncDirection.TWO_WAY, conflict = ConflictStrategy.ASK)
        write(local, "c.txt", "base"); sync(pair)                  // upload
        sync(pair)                                                 // reconcile: record remote baseline (etag/mtime)
        Thread.sleep(1100)
        write(local, "c.txt", "LOCAL-change")                       // change local
        putRemote(remote, "c.txt", "REMOTE-change")                // change remote out-of-band
        val r = sync(pair)
        assertEquals("a conflict must be detected", 1, r.conflicts)
        // ASK must not silently overwrite either side
        assertEquals("LOCAL-change", File(local, "c.txt").readText())
        assertEquals("REMOTE-change", remoteText("$remote/c.txt"))
    }

    // ── 12. Conflict KEEP_NEWEST: newer local wins and uploads ────────────────
    @Test fun twoWay_keepNewest_newerLocalWins() = runBlocking {
        val (pair, local, remote) = newPair("newest", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_NEWEST)
        write(local, "n.txt", "base"); sync(pair)
        putRemote(remote, "n.txt", "remote-older")
        Thread.sleep(1100)
        write(local, "n.txt", "local-newer")                       // local is newer than remote
        sync(pair)
        assertEquals("newer local must win", "local-newer", remoteText("$remote/n.txt"))
    }

    // ── 13b. Special & non-ASCII filenames upload (WebDAV URL/header encoding) ─
    @Test fun specialAndNonAsciiNames_upload() = runBlocking {
        val (pair, local, remote) = newPair("special", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        write(local, "naïve café.txt", "accents")   // non-ASCII (broke MOVE Destination header)
        write(local, "a&b (1).txt", "ampersand")     // & ( ) space
        write(local, "日本語.txt", "cjk")              // multibyte unicode
        write(local, "my photo.txt", "space")
        val r = sync(pair)
        assertEquals("all special-name files must upload", 4, r.uploaded)
        assertEquals(0, r.failedFiles)
        val names = remoteNames(remote)
        assertTrue("naïve café.txt" in names)
        assertTrue("a&b (1).txt" in names)
        assertTrue("日本語.txt" in names)
        assertTrue("my photo.txt" in names)
    }

    // ── 13c. Volume: 100+ files (incl. subfolders & non-ASCII) upload, 0 fails ─
    @Test fun volume_hundredFiles_allUploadNoFailures() = runBlocking {
        val (pair, local, remote) = newPair("vol100", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        repeat(100) { i -> write(local, "f_%03d.txt".format(i), "payload $i ".repeat(30)) }
        write(local, "sub/nested_a.txt", "n1")
        write(local, "sub/deep/nested_b.txt", "n2")
        write(local, "naïve café.txt", "accented")
        val r = sync(pair)
        assertEquals("no file may fail under volume", 0, r.failedFiles)
        assertEquals("all 103 files upload", 103, r.uploaded)
        assertEquals("100 flat files present on cloud", 100, remoteNames(remote).count { it.startsWith("f_") })
        assertTrue("non-ASCII name present too", "naïve café.txt" in remoteNames(remote))
        // re-sync is a clean no-op (no phantom re-uploads / loops at volume)
        val r2 = sync(pair)
        assertEquals(0, r2.uploaded); assertEquals(0, r2.deleted); assertEquals(0, r2.failedFiles)
    }

    // ── 14. Content integrity: binary-ish bytes round-trip exactly ────────────
    @Test fun contentIntegrity_roundTrip() = runBlocking {
        val (pair, local, remote) = newPair("integrity", SyncDirection.TWO_WAY)
        val payload = (0..5000).joinToString("") { "Ω$it·" }
        write(local, "big.txt", payload); sync(pair)
        assertEquals(payload, remoteText("$remote/big.txt"))
    }

    // ══ EDGE CASES & STRESS ═══════════════════════════════════════════════════

    private fun writeBytes(dir: File, rel: String, bytes: ByteArray) =
        File(dir, rel).apply { parentFile?.mkdirs() }.writeBytes(bytes)

    // 15. Empty (0-byte) file uploads correctly
    @Test fun emptyFile_uploads() = runBlocking {
        val (pair, local, remote) = newPair("empty", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        write(local, "zero.txt", "")
        val r = sync(pair)
        assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
        assertEquals(0L, provider.listFiles(remote).getOrThrow().first { it.name == "zero.txt" }.sizeBytes)
    }

    // 16. Large file (20 MB) uploads + downloads byte-intact (OOM / streaming guard)
    @Test fun largeFile_intactRoundTrip() = runBlocking {
        val (pair, local, remote) = newPair("large", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        val size = 20 * 1024 * 1024
        val bytes = ByteArray(size).also { java.util.Random(42).nextBytes(it) }
        writeBytes(local, "big.bin", bytes)
        val r = sync(pair)
        assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
        assertEquals(size.toLong(), provider.listFiles(remote).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
        val out = ByteArrayOutputStream(size); provider.downloadFile("$remote/big.bin", out).getOrThrow()
        val dl = out.toByteArray()
        assertEquals(size, dl.size)
        assertArrayEquals(bytes.copyOfRange(0, 4096), dl.copyOfRange(0, 4096))
        assertArrayEquals(bytes.copyOfRange(size - 4096, size), dl.copyOfRange(size - 4096, size))
    }

    // 17. Deeply nested path (8 levels) is created + uploaded
    @Test fun deepNesting_uploads() = runBlocking {
        val (pair, local, remote) = newPair("deep", SyncDirection.UPLOAD_ONLY, recursive = true)
        write(local, "a/b/c/d/e/f/g/deep.txt", "deep")
        assertEquals(0, sync(pair).failedFiles)
        assertTrue("deep.txt" in remoteNames("$remote/a/b/c/d/e/f/g"))
    }

    // 18. Unicode FOLDER names (not just files) are created + encoded
    @Test fun unicodeFolderNames_upload() = runBlocking {
        val (pair, local, remote) = newPair("ufolder", SyncDirection.UPLOAD_ONLY, recursive = true)
        write(local, "Фото/café/x.txt", "u")
        assertEquals(0, sync(pair).failedFiles)
        assertTrue("x.txt" in remoteNames("$remote/Фото/café"))
    }

    // 19. Very long filename (200 chars)
    @Test fun veryLongFilename_uploads() = runBlocking {
        val (pair, local, remote) = newPair("longname", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        val name = "L".repeat(200) + ".txt"
        write(local, name, "x")
        assertEquals(0, sync(pair).failedFiles)
        assertTrue(name in remoteNames(remote))
    }

    // 20. File with no extension
    @Test fun noExtensionFile_uploads() = runBlocking {
        val (pair, local, remote) = newPair("noext", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        write(local, "README", "x")
        assertEquals(1, sync(pair).uploaded)
        assertTrue("README" in remoteNames(remote))
    }

    // 21. Idempotency / loop guard — repeated syncs do NOT re-upload anything
    @Test fun idempotent_repeatedSyncsNoPhantomUploads() = runBlocking {
        val (pair, local, remote) = newPair("idem", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        repeat(10) { i -> write(local, "x_$i.txt", "v$i") }
        assertEquals(10, sync(pair).uploaded)
        repeat(4) {
            val r = sync(pair)
            assertEquals("sync must be idempotent (no re-upload loop)", 0, r.uploaded)
            assertEquals(0, r.deleted); assertEquals(0, r.failedFiles)
        }
    }

    // 22. Bulk update — modifying many files re-uploads exactly those
    @Test fun bulkUpdate_reuploadsChanged() = runBlocking {
        val (pair, local, remote) = newPair("bulkupd", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        repeat(10) { i -> write(local, "u_$i.txt", "v1") }; sync(pair)
        Thread.sleep(1100)
        repeat(10) { i -> write(local, "u_$i.txt", "v2-updated-content") }
        assertEquals(10, sync(pair).uploaded)
        assertEquals("v2-updated-content", remoteText("$remote/u_0.txt"))
    }

    // 23. Bulk delete (MIRROR two-way) propagates all deletions
    @Test fun mirror_bulkDeletePropagates() = runBlocking {
        val (pair, local, remote) = newPair("bulkdel", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
        repeat(10) { i -> write(local, "d_$i.txt", "x") }; sync(pair)
        repeat(10) { i -> File(local, "d_$i.txt").delete() }
        assertEquals(10, sync(pair).deleted)
        assertEquals(0, remoteNames(remote).count { it.startsWith("d_") })
    }

    // 24. Bulk download (download-only) pulls all remote files
    @Test fun downloadOnly_bulkPull() = runBlocking {
        val (pair, local, remote) = newPair("bulkdl", SyncDirection.DOWNLOAD_ONLY)
        repeat(10) { i -> putRemote(remote, "r_$i.txt", "cloud$i") }
        assertEquals(10, sync(pair).downloaded)
        assertEquals(10, local.listFiles()!!.count { it.name.startsWith("r_") })
    }

    // 25. KEEP_BOTH conflict strategy records a conflict (no silent clobber)
    @Test fun twoWay_keepBoth_recordsConflict() = runBlocking {
        val (pair, local, remote) = newPair("keepboth", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_BOTH)
        write(local, "c.txt", "base"); sync(pair); sync(pair) // baseline + reconcile
        Thread.sleep(1100)
        write(local, "c.txt", "LOCAL"); putRemote(remote, "c.txt", "REMOTE")
        assertEquals(1, sync(pair).conflicts)
        assertEquals("LOCAL", File(local, "c.txt").readText())
        assertEquals("REMOTE", remoteText("$remote/c.txt"))
    }

    // 26. Min-size filter skips tiny files
    @Test fun filters_minSizeSkipsTiny() = runBlocking {
        val (pair, local, remote) = newPair("minsize", SyncDirection.UPLOAD_ONLY, minKb = 1)
        write(local, "tiny.txt", "x")                       // < 1 KB
        write(local, "big.txt", "A".repeat(2048))           // ~2 KB
        sync(pair)
        val n = remoteNames(remote)
        assertFalse("tiny.txt" in n); assertTrue("big.txt" in n)
    }

    // 27. Include-extension filter uploads only matching files
    @Test fun filters_includeExtensionOnly() = runBlocking {
        val (pair, local, remote) = newPair("incl", SyncDirection.UPLOAD_ONLY, includeExtensions = "jpg")
        write(local, "keep.jpg", "x"); write(local, "skip.txt", "y")
        sync(pair)
        val n = remoteNames(remote)
        assertTrue("keep.jpg" in n); assertFalse("skip.txt" in n)
    }

    // 28. Whole-folder wipe locally (MIRROR) removes all remote copies
    @Test fun mirror_emptyLocalWipesRemote() = runBlocking {
        val (pair, local, remote) = newPair("wipe", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
        repeat(5) { i -> write(local, "w_$i.txt", "x") }; sync(pair)
        local.listFiles()!!.forEach { it.delete() }
        assertEquals(5, sync(pair).deleted)
        assertEquals(0, remoteNames(remote).count { it.startsWith("w_") })
    }

    // ══ INTERRUPTION / ATOMICITY ══════════════════════════════════════════════

    // 29. A write that fails mid-stream must leave the existing file intact (no truncation)
    @Test fun atomicWrite_failedWriteLeavesOriginalIntact() = runBlocking {
        val dir = File(ctx.cacheDir, "atomic_${System.currentTimeMillis()}").apply { mkdirs() }
        localDirs += dir
        File(dir, "f.txt").writeText("ORIGINAL-GOOD-CONTENT")
        val accessor = LocalAccessor.JavaFile(dir)
        val outcome = runCatching {
            accessor.writeAtomically("f.txt") { os ->
                os.write("PARTIAL-GARBAGE".toByteArray()); os.flush()
                throw java.io.IOException("simulated network drop mid-download")
            }
        }
        assertTrue("the failed write must propagate", outcome.isFailure)
        assertEquals("original must be untouched after a failed write", "ORIGINAL-GOOD-CONTENT", File(dir, "f.txt").readText())
        assertTrue("no leftover .sfpart temp", dir.listFiles()!!.none { it.name.endsWith(".sfpart") })
    }

    // 30. A sync interrupted partway (provider fails after N files) loses nothing and the
    //     next sync completes the rest with all content intact.
    @Test fun interruptedSync_resumesCleanlyNoCorruption() = runBlocking {
        val (pair, local, remote) = newPair("interrupt", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
        repeat(10) { i -> write(local, "i_$i.txt", "content-$i-".repeat(50)) }
        // Provider that simulates a connection drop after 4 successful uploads.
        val flaky = object : CloudProvider by provider {
            private val n = java.util.concurrent.atomic.AtomicInteger(0)
            override suspend fun uploadFile(localStream: java.io.InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> =
                if (n.incrementAndGet() > 4) Result.failure(java.io.IOException("connection dropped"))
                else provider.uploadFile(localStream, remotePath, sizeBytes, onProgress)
        }
        val r1 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), flaky)
        assertTrue("some files should fail on the dropped sync", r1.failedFiles > 0)
        // Re-sync with the healthy provider completes the rest.
        val r2 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), provider)
        assertEquals("re-sync must complete with no failures", 0, r2.failedFiles)
        assertEquals("all 10 files end up on the cloud", 10, remoteNames(remote).count { it.startsWith("i_") })
        assertEquals("content intact (no truncation)", "content-0-".repeat(50), remoteText("$remote/i_0.txt"))
    }
}
