package com.github.nullptroma.wallenc.usecases

import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageAccessor
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageSyncGroupStore
import com.github.nullptroma.wallenc.usecases.fakes.FakeVaultsManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant

class StorageSyncEngineTest {

    private fun norm(path: String): String = StorageSyncPaths.normalize(path)

    @Test
    fun syncAllGroupsReportsNoGroupsWhenEmpty() = runBlocking {
        val labels = mutableListOf<TaskProgressLabel?>()
        val engine = createEngine(storages = emptyList(), groups = emptyList())
        engine.syncAllGroups { _, label -> labels.add(label) }
        assertTrue(labels.any { it is TaskProgressLabel.SyncNoGroups })
    }

    @Test
    fun syncGroupCopiesFileFromSourceToTarget() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        val path = "shared/doc.txt"
        val payload = "sync-payload".encodeToByteArray()
        source.putFile(path, payload)

        val entry = StorageSyncJournalEntry(
            path = path,
            operation = StorageSyncOperation.UPSERT,
            revision = StorageSyncRevision(
                sequence = 1L,
                actorId = "actor-a",
                createdAt = Instant.parse("2024-01-01T00:00:00Z"),
            ),
            size = payload.size.toLong(),
            originStorageUuid = source.uuid,
        )
        source.addSyncJournalEntry(entry)

        val groupId = "group-1"
        val group = StorageSyncGroup(
            id = groupId,
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val labels = mutableListOf<TaskProgressLabel?>()
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        engine.syncGroup(groupId) { _, label -> labels.add(label) }

        assertArrayEquals(payload, target.fileBytes(path))
        assertTrue(labels.any { it is TaskProgressLabel.SyncGroupCompleted })
        assertTrue(target.syncJournalEntries().any { it.path == norm(path) })
    }

    @Test
    fun syncGroupSkippedWhenFewerThanTwoStorages() = runBlocking {
        val only = FakeStorage()
        val groupId = "solo"
        val group = StorageSyncGroup(
            id = groupId,
            storageUuids = setOf(only.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val labels = mutableListOf<TaskProgressLabel?>()
        val engine = createEngine(storages = listOf(only), groups = listOf(group))
        engine.syncGroup(groupId) { _, label -> labels.add(label) }
        assertTrue(labels.any { it is TaskProgressLabel.SyncGroupSkippedTooFewStorages })
    }

    @Test
    fun syncGroupDeleteRemovesFileOnTarget() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        val path = "obsolete.bin"
        target.putFile(path, "old".encodeToByteArray())

        val entry = StorageSyncJournalEntry(
            path = path,
            operation = StorageSyncOperation.DELETE,
            revision = StorageSyncRevision(
                sequence = 2L,
                actorId = "actor-b",
                createdAt = Instant.parse("2024-06-01T00:00:00Z"),
            ),
        )
        source.addSyncJournalEntry(entry)

        val group = StorageSyncGroup(
            id = "delete-group",
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, _ -> }

        assertNull(target.fileBytes(path))
        val targetEntry = target.syncJournalEntries().single { it.path == norm(path) }
        assertEquals(2L, targetEntry.revision.sequence)
        assertEquals("actor-b", targetEntry.revision.actorId)
        assertEquals(StorageSyncOperation.DELETE, targetEntry.operation)
    }

    @Test
    fun syncSkipsWhenTargetRevisionAlreadyWinner() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        val path = "already-synced.txt"
        val payload = "same".encodeToByteArray()
        source.putFile(path, payload)
        target.putFile(path, payload)

        val winner = StorageSyncJournalEntry(
            path = path,
            operation = StorageSyncOperation.UPSERT,
            revision = StorageSyncRevision(
                sequence = 5L,
                actorId = "winner-actor",
                createdAt = Instant.parse("2024-08-01T00:00:00Z"),
            ),
            size = payload.size.toLong(),
        )
        source.addSyncJournalEntry(winner)
        target.addSyncJournalEntry(winner)

        val group = StorageSyncGroup(
            id = "skip-group",
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, _ -> }

        val targetJournal = target.syncJournalEntries().single { it.path == norm(path) }
        assertEquals(winner.revision, targetJournal.revision)
        assertEquals(winner.operation, targetJournal.operation)
    }

    @Test
    fun openReadDoesNotChangeJournal() = runBlocking {
        val storage = FakeStorage()
        val path = "read-only.txt"
        storage.putFile(path, "data".encodeToByteArray())
        val before = storage.syncJournalEntries().size

        storage.accessor.openRead(path).use { it.readBytes() }

        assertEquals(before, storage.syncJournalEntries().size)
    }

    @Test
    fun deleteWithRecordSyncJournalFalseDoesNotBumpSequence() = runBlocking {
        val storage = FakeStorage()
        val path = "to-delete.txt"
        storage.putFile(path, "x".encodeToByteArray())
        storage.addSyncJournalEntry(
            StorageSyncJournalEntry(
                path = path,
                operation = StorageSyncOperation.UPSERT,
                revision = StorageSyncRevision(10L, "prior", Instant.EPOCH),
            ),
        )

        storage.accessor.delete(path, recordSyncJournal = false)

        assertNull(storage.fileBytes(path))
        val entry = storage.syncJournalEntries().single { it.path == norm(path) }
        assertEquals(10L, entry.revision.sequence)
        assertEquals(StorageSyncOperation.UPSERT, entry.operation)
    }

    @Test
    fun syncGroupTrashSoftDeletesOnTarget() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        val path = "trashed/doc.txt"
        val payload = "keep-in-trash".encodeToByteArray()
        source.putFile(path, payload)
        target.putFile(path, payload)

        val entry = StorageSyncJournalEntry(
            path = path,
            operation = StorageSyncOperation.TRASH,
            revision = StorageSyncRevision(
                sequence = 3L,
                actorId = "actor-trash",
                createdAt = Instant.parse("2024-07-01T00:00:00Z"),
            ),
        )
        source.addSyncJournalEntry(entry)

        val group = StorageSyncGroup(
            id = "trash-group",
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, _ -> }

        assertArrayEquals(payload, target.fileBytes(path))
        assertTrue(norm(path) in (target.accessor as FakeStorageAccessor).trashedPaths)
        val targetEntry = target.syncJournalEntries().single { it.path == norm(path) }
        assertEquals(3L, targetEntry.revision.sequence)
        assertEquals("actor-trash", targetEntry.revision.actorId)
        assertEquals(StorageSyncOperation.TRASH, targetEntry.operation)
    }

    @Test
    fun syncGroupStopsWhenLockCannotBeAcquired() = runBlocking {
        val first = FakeStorage()
        val second = FakeStorage().apply { setAcquireLockResult(false) }
        val group = StorageSyncGroup(
            id = "lock-fail",
            storageUuids = setOf(first.uuid, second.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        first.addSyncJournalEntry(
            StorageSyncJournalEntry(
                path = "a.txt",
                operation = StorageSyncOperation.UPSERT,
                revision = StorageSyncRevision(1L, "x", Instant.EPOCH),
            ),
        )
        val labels = mutableListOf<TaskProgressLabel?>()
        val engine = createEngine(
            storages = listOf(first, second),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, label -> labels.add(label) }
        assertTrue(labels.any { it is TaskProgressLabel.SyncGroupLockFailed })
        assertNull((first.accessor as FakeStorageAccessor).syncLock)
        assertNull((second.accessor as FakeStorageAccessor).syncLock)
    }

    @Test
    fun syncGroupReleasesLocksAfterSuccessfulSync() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        source.addSyncJournalEntry(
            StorageSyncJournalEntry(
                path = "a.txt",
                operation = StorageSyncOperation.UPSERT,
                revision = StorageSyncRevision(1L, "x", Instant.EPOCH),
            ),
        )
        source.putFile("a.txt", "x".encodeToByteArray())
        val group = StorageSyncGroup(
            id = "ok",
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, _ -> }
        assertNull((source.accessor as FakeStorageAccessor).syncLock)
        assertNull((target.accessor as FakeStorageAccessor).syncLock)
    }

    @Test
    fun syncGroupReleasesLocksWhenJournalReadFails() = runBlocking {
        val first = FakeStorage()
        val second = FakeStorage()
        (first.accessor as FakeStorageAccessor).readSyncJournalThrows =
            IllegalStateException("journal read failed")
        val group = StorageSyncGroup(
            id = "journal-fail",
            storageUuids = setOf(first.uuid, second.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(first, second),
            groups = listOf(group),
        )
        runCatching { engine.syncGroup(group.id) { _, _ -> } }
        assertNull((first.accessor as FakeStorageAccessor).syncLock)
        assertNull((second.accessor as FakeStorageAccessor).syncLock)
    }

    @Test
    fun syncGroupCooperativeCancellationReleasesLocks() = runBlocking {
        val source = FakeStorage()
        val target = FakeStorage()
        val path = "slow.txt"
        val payload = "payload".encodeToByteArray()
        source.putFile(path, payload)
        (source.accessor as FakeStorageAccessor).openReadDelayMs = 5_000
        source.addSyncJournalEntry(
            StorageSyncJournalEntry(
                path = path,
                operation = StorageSyncOperation.UPSERT,
                revision = StorageSyncRevision(1L, "actor", Instant.EPOCH),
                size = payload.size.toLong(),
            ),
        )
        val group = StorageSyncGroup(
            id = "cancel-group",
            storageUuids = setOf(source.uuid, target.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val engine = createEngine(
            storages = listOf(source, target),
            groups = listOf(group),
        )
        val job = async {
            engine.syncGroup(group.id) { _, _ -> }
        }
        kotlinx.coroutines.delay(50)
        job.cancel()
        try {
            job.await()
        } catch (_: CancellationException) {
            // expected
        }
        assertNull((source.accessor as FakeStorageAccessor).syncLock)
        assertNull((target.accessor as FakeStorageAccessor).syncLock)
    }

    @Test
    fun syncGroupReleasesLocksWhenJournalEmpty() = runBlocking {
        val first = FakeStorage()
        val second = FakeStorage()
        val group = StorageSyncGroup(
            id = "empty-journal",
            storageUuids = setOf(first.uuid, second.uuid),
            encryptionKind = StorageSyncGroupEncryptionKind.NONE,
        )
        val labels = mutableListOf<TaskProgressLabel?>()
        val engine = createEngine(
            storages = listOf(first, second),
            groups = listOf(group),
        )
        engine.syncGroup(group.id) { _, label -> labels.add(label) }
        assertTrue(labels.any { it is TaskProgressLabel.SyncGroupNoJournalEntries })
        assertNull((first.accessor as FakeStorageAccessor).syncLock)
        assertNull((second.accessor as FakeStorageAccessor).syncLock)
    }

    private fun createEngine(
        storages: List<FakeStorage>,
        groups: List<StorageSyncGroup>,
    ): StorageSyncEngine {
        val vaultsManager = FakeVaultsManager(storages)
        val findStorage = FindStorageUseCase(vaultsManager)
        return StorageSyncEngine(
            vaultsManager = vaultsManager,
            groupStore = FakeStorageSyncGroupStore(groups),
            findStorageUseCase = findStorage,
        )
    }
}
