package com.syncflow.domain.sync

import android.content.Context
import android.net.Uri
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.RemoteFile
import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncPair
import com.syncflow.domain.model.SyncStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import timber.log.Timber
import java.io.File
import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject

class SyncEngine @Inject constructor(
    private val syncPairDao: SyncPairDao,
    private val fileStateDao: SyncFileStateDao,
    private val conflictDao: SyncConflictDao,
    private val eventDao: SyncEventDao,
    @ApplicationContext private val context: Context,
) {
    suspend fun sync(
        pair: SyncPair,
        provider: CloudProvider,
        onProgress: (suspend (uploaded: Int, downloaded: Int, deleted: Int, bytesTransferred: Long) -> Unit)? = null,
    ): SyncResult {
        syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
        logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)

        return try {
            val result = performSync(pair, provider, onProgress = onProgress)
            val finalStatus = when {
                result.failedFiles > 0 && result.conflicts > 0 -> SyncStatus.CONFLICT
                result.failedFiles > 0 -> SyncStatus.PARTIAL
                result.conflicts > 0 -> SyncStatus.CONFLICT
                else -> SyncStatus.SUCCESS
            }
            syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts, result.uploaded, result.downloaded, result.deleted, result.bytesTransferred)
            logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "↑${result.uploaded} ↓${result.downloaded} ✕${result.deleted} ✗${result.failedFiles}", result.bytesTransferred)
            result
        } catch (e: Exception) {
            Timber.e(e, "Sync failed for pair ${pair.id}")
            syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0, 0, 0, 0, 0L)
            logEvent(pair.id, SyncEventType.SYNC_FAILED, null, e.message, 0)
            SyncResult(failedFiles = 1, error = e)
        }
    }

    private fun makeAccessor(localPath: String): LocalAccessor =
        if (localPath.startsWith("content://"))
            LocalAccessor.Saf(Uri.parse(localPath), context.contentResolver)
        else
            LocalAccessor.JavaFile(File(localPath))

    /**
     * Recursively collect every FILE under [basePath] on the remote, descending into each
     * subdirectory (one Depth:1 PROPFIND per directory). Directories are never returned as
     * files — only their contents. The provider already drops the parent entry from each
     * listing, so children-only is returned; the explicit self-path guard prevents any
     * pathological infinite recursion. This MUST mirror the recursive local walk: otherwise
     * files in remote subfolders appear absent and a TWO_WAY/MIRROR sync deletes them locally.
     */
    private suspend fun listRemoteFilesRecursive(
        provider: CloudProvider,
        basePath: String,
        depth: Int = 0,
    ): List<RemoteFile> {
        if (depth > 64) {
            Timber.w("SyncEngine: remote recursion depth limit hit at $basePath")
            return emptyList()
        }
        val out = mutableListOf<RemoteFile>()
        for (entry in provider.listFiles(basePath).getOrThrow()) {
            if (entry.isDirectory) {
                if (entry.path.trimEnd('/') != basePath.trimEnd('/')) {
                    out += listRemoteFilesRecursive(provider, entry.path, depth + 1)
                }
            } else {
                out += entry
            }
        }
        return out
    }

    private suspend fun performSync(
        pair: SyncPair,
        provider: CloudProvider,
        isRetry: Boolean = false,
        onProgress: (suspend (Int, Int, Int, Long) -> Unit)? = null,
    ): SyncResult {
        val accessor = makeAccessor(pair.localPath)
        var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
        // The local walk is RECURSIVE, so the remote listing must be too. Listing only the top
        // level (Depth:1) made every file inside a remote subfolder look "missing from remote",
        // which on a TWO_WAY/MIRROR pair triggered DELETE_LOCAL and wiped those files off the
        // device (data loss). Walk the remote tree so subfolder files are matched, not deleted.
        // Exclusions are filtered on BOTH sides. The local walk already drops excluded files,
        // but the remote listing did not, so any excluded path that already existed on the
        // server (e.g. a previously-uploaded ".thumbnails" entry) looked "local-missing" and
        // got DELETE_REMOTE'd every cycle — endless churn as Android regenerates the cache.
        // Filtering remote + the merged path set makes excluded paths invisible to the engine:
        // never uploaded, downloaded, or deleted on either side.
        val remoteFiles = try {
            listRemoteFilesRecursive(provider, pair.remotePath)
                .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
                .filterKeys { !isExcludedPath(it, pair) }
        } catch (e: Exception) {
            // A brand-new pair whose remote folder doesn't exist yet 404s on the first listing —
            // that simply means "empty remote", so everything uploads. This is ONLY safe with no
            // prior sync state: on a first sync every known==null, so no DELETE_* branch can fire
            // (verified in syncDecide). Once state exists, a 404 means the remote folder vanished
            // or is unreachable, and treating it as empty would mirror-delete every local file —
            // so we rethrow and let the sync fail loudly instead of destroying data.
            if (knownStates.isEmpty() && isRemoteNotFound(e)) {
                runCatching { ensureRemoteBaseDir(provider, pair.remotePath) }
                emptyMap()
            } else throw e
        }
        val localFiles = accessor.walkFiles(pair)

        // Self-healing: if every known-state path is absent from the current local scan but
        // the local folder does have files, the localPath was changed without clearing state.
        // The stale records would cause every old file to look like "DELETE_REMOTE" and every
        // new file to re-upload indefinitely. Wipe and retry once as a fresh initial sync.
        if (!isRetry && knownStates.isNotEmpty() && localFiles.isNotEmpty() &&
            knownStates.keys.none { it in localFiles }) {
            Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
            fileStateDao.deleteForPair(pair.id)
            return performSync(pair, provider, isRetry = true, onProgress = onProgress)
        }

        // knownStates may still hold records for now-excluded paths (e.g. thumbnails uploaded
        // by an older build). Drop them from the work set so they aren't acted on; their stale
        // state rows are harmless and ignored.
        val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys)
            .filter { !isExcludedPath(it, pair) }
            .toSet()
        val hasPriorSyncState = knownStates.isNotEmpty()
        val semaphore = Semaphore(2)  // limit concurrency to be gentle on the server
        val uploadedAtomic = AtomicInteger(0)
        val downloadedAtomic = AtomicInteger(0)
        val deletedAtomic = AtomicInteger(0)
        val bytesAtomic = AtomicLong(0L)

        // Each async block returns its outcome; no shared mutable state across coroutines.
        data class FileOutcome(
            val uploaded: Int = 0, val downloaded: Int = 0, val deleted: Int = 0,
            val skipped: Int = 0, val failed: Int = 0, val conflicts: Int = 0,
            val bytesTransferred: Long = 0L,
            val newState: SyncFileStateEntity? = null,
        )

        val outcomes: List<FileOutcome> = coroutineScope {
            allPaths.map { rel ->
                async {
                    semaphore.withPermit {
                        // Defense-in-depth against a malicious/compromised remote returning a
                        // path that escapes the sync root (e.g. "../../evil"). Skip rather than
                        // write outside pair.localPath / pair.remotePath.
                        if (isUnsafeSyncPath(rel)) {
                            Timber.w("SyncEngine: skipping unsafe path for pair ${pair.id}: $rel")
                            logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, "unsafe path", 0)
                            return@withPermit FileOutcome(skipped = 1)
                        }
                        val local = localFiles[rel]
                        val remote = remoteFiles[rel]
                        val known = knownStates[rel]
                        val decision = syncDecide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known, hasPriorSyncState)

                        when (decision) {
                            SyncDecision.UPLOAD -> {
                                val bytes = runCatching {
                                    ensureRemoteDirs(provider, pair.remotePath, rel)
                                    accessor.openInputStream(rel)?.use { stream ->
                                        provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
                                    }
                                    local!!.sizeBytes
                                }.getOrElse { e ->
                                    Timber.e(e, "Upload failed: $rel")
                                    logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
                                    return@withPermit FileOutcome(failed = 1)
                                }
                                logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
                                val up = uploadedAtomic.incrementAndGet()
                                bytesAtomic.addAndGet(bytes)
                                onProgress?.invoke(up, downloadedAtomic.get(), deletedAtomic.get(), bytesAtomic.get())
                                // Don't store remote metadata from upload response — the server (Nextcloud etc.)
                                // may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
                                // null forces the SKIP reconciliation on the next sync to fill it in from the
                                // directory listing, which is the same source all future syncs will use.
                                FileOutcome(uploaded = 1, bytesTransferred = bytes,
                                    newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null))
                            }
                            SyncDecision.DOWNLOAD -> {
                                val bytes = runCatching {
                                    accessor.writeAtomically(rel) { stream ->
                                        provider.downloadFile("${pair.remotePath}/$rel", stream) { }.getOrThrow()
                                    }
                                    remote!!.sizeBytes
                                }.getOrElse { e ->
                                    Timber.e(e, "Download failed: $rel")
                                    logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
                                    return@withPermit FileOutcome(failed = 1)
                                }
                                // Read the actual local mtime written by the OS/SAF after download.
                                val localMtime = runCatching { accessor.lastModifiedMs(rel) }
                                    .getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
                                    ?: System.currentTimeMillis()
                                logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
                                val down = downloadedAtomic.incrementAndGet()
                                bytesAtomic.addAndGet(bytes)
                                onProgress?.invoke(uploadedAtomic.get(), down, deletedAtomic.get(), bytesAtomic.get())
                                FileOutcome(downloaded = 1, bytesTransferred = bytes,
                                    newState = buildState(pair.id, rel,
                                        LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
                                        remoteAfterTransfer = remote,
                                        storeLocalMtime = false))
                            }
                            SyncDecision.DELETE_LOCAL -> {
                                val deleted = accessor.delete(rel)
                                if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
                                fileStateDao.delete(pair.id, rel)
                                logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
                                val del = deletedAtomic.incrementAndGet()
                                onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
                                FileOutcome(deleted = 1)
                            }
                            SyncDecision.DELETE_REMOTE -> {
                                if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) {
                                    val archivePath = "${pair.remotePath}/_Deleted/$rel"
                                    runCatching {
                                        // Create the _Deleted base itself first — ensureRemoteDirs only
                                        // makes sub-parents of rel, so for a top-level file the MOVE
                                        // would otherwise fail with a missing-parent error.
                                        provider.createDirectory("${pair.remotePath}/_Deleted")
                                        ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel)
                                        provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
                                    }.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
                                    fileStateDao.delete(pair.id, rel)
                                    logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "archived", 0)
                                } else {
                                    runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
                                        .onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
                                    fileStateDao.delete(pair.id, rel)
                                    logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
                                }
                                val del = deletedAtomic.incrementAndGet()
                                onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
                                FileOutcome(deleted = 1)
                            }
                            SyncDecision.CONFLICT -> {
                                conflictDao.insert(SyncConflictEntity(
                                    syncPairId = pair.id,
                                    relativePath = rel,
                                    localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } ?: Instant.EPOCH,
                                    localSizeBytes = local?.sizeBytes ?: 0L,
                                    remoteModifiedAt = remote?.modifiedAt ?: Instant.EPOCH,
                                    remoteSizeBytes = remote?.sizeBytes ?: 0L,
                                    resolution = null,
                                    detectedAt = Instant.now(),
                                ))
                                logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
                                FileOutcome(conflicts = 1)
                            }
                            SyncDecision.SKIP -> {
                                // Save state whenever both sides are present and state is absent or
                                // incomplete (post-upload null metadata). Without a baseline record,
                                // a subsequent local deletion would look like an unseen remote file
                                // and be re-downloaded instead of triggering DELETE_REMOTE.
                                val saveState = local != null && remote != null && (
                                    known == null ||
                                    known.remoteModifiedAt == null || known.localModifiedAt == null
                                )
                                if (saveState) {
                                    FileOutcome(skipped = 1, newState = buildState(pair.id, rel, local, remoteAfterTransfer = remote))
                                } else {
                                    FileOutcome(skipped = 1)
                                }
                            }
                        }
                    }
                }
            }.awaitAll()
        }

        fileStateDao.upsertAll(outcomes.mapNotNull { it.newState })
        return SyncResult(
            uploaded = outcomes.sumOf { it.uploaded },
            downloaded = outcomes.sumOf { it.downloaded },
            deleted = outcomes.sumOf { it.deleted },
            skipped = outcomes.sumOf { it.skipped },
            failedFiles = outcomes.sumOf { it.failed },
            conflicts = outcomes.sumOf { it.conflicts },
            bytesTransferred = outcomes.sumOf { it.bytesTransferred },
        )
    }

    /**
     * Create every path component of the pair's remote root (e.g. /Backup/DCIM → MKCOL /Backup
     * then /Backup/DCIM) so the first uploads of a brand-new pair have a parent to land in.
     * Only called on a first sync where the root listing 404'd; existing-dir MKCOLs fail harmlessly.
     */
    private suspend fun ensureRemoteBaseDir(provider: CloudProvider, remotePath: String) {
        val parts = remotePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }
        var current = ""
        for (part in parts) {
            current = "$current/$part"
            provider.createDirectory(current).onFailure { e -> Timber.w("MKCOL base $current: ${e.message}") }
        }
    }

    private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
        val parts = rel.replace('\\', '/').split('/')
        var currentPath = remotePairPath
        for (part in parts.dropLast(1)) {
            currentPath = "$currentPath/$part"
            provider.createDirectory(currentPath).onFailure { e ->
                Timber.w("MKCOL $currentPath: ${e.message}")
            }
        }
    }

    private fun buildState(
        pairId: Long,
        rel: String,
        local: LocalFileInfo?,
        remoteAfterTransfer: RemoteFile?,
        storeLocalMtime: Boolean = true,
    ) = SyncFileStateEntity(
        syncPairId = pairId,
        relativePath = rel,
        // When storeLocalMtime=false, leave localModifiedAt null so the SKIP reconciliation
        // pass on the next sync reads it from the walkFiles cursor (avoids SAF stale-mtime loops).
        localModifiedAt = if (storeLocalMtime) local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } else null,
        localSizeBytes = local?.sizeBytes ?: 0L,
        localHash = null,
        remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
        remoteSizeBytes = remoteAfterTransfer?.sizeBytes ?: 0L,
        remoteEtag = remoteAfterTransfer?.etag,
        lastSyncedAt = Instant.now(),
        syncedHash = null,
    )

    private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
        eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
    }
}

// Top-level so unit tests can call it directly without instantiating SyncEngine.
internal fun syncDecide(
    direction: SyncDirection,
    conflictStrategy: ConflictStrategy,
    deleteBehavior: DeleteBehavior,
    local: LocalFileInfo?,
    remote: RemoteFile?,
    known: SyncFileStateEntity?,
    hasPriorSyncState: Boolean = false,
): SyncDecision {
    val localExists = local != null
    val remoteExists = remote != null

    // Treat null known timestamps as "not yet recorded" — don't treat as changed.
    // The SKIP reconciliation pass will fill them in on the next sync.
    // Use second-precision for both sides: FAT32 has 2-second mtime resolution, WebDAV
    // RFC-1123 has 1-second resolution, so millisecond comparison causes phantom "changed"
    // detections and rewrite loops after a fresh download/upload.
    val localChanged = known == null ||
        (localExists && known.localModifiedAt != null &&
            local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond)
    val remoteChanged = known == null ||
        (remoteExists && known.remoteModifiedAt != null &&
            (remote!!.etag != known.remoteEtag ||
             remote.modifiedAt.epochSecond != known.remoteModifiedAt.epochSecond))

    return when {
        !localExists && !remoteExists -> SyncDecision.SKIP

        localExists && !remoteExists -> when {
            known == null -> when (direction) {
                SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
                else -> SyncDecision.SKIP
            }
            else -> when {
                deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
                direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL
                else -> SyncDecision.SKIP
            }
        }

        !localExists && remoteExists -> when {
            known == null -> {
                // No state record: could be a new remote file OR a file whose state was lost.
                // Downloading is always safer than deleting — if the user deleted the local
                // copy intentionally, the state record will still exist (known != null) and
                // the else-branch below correctly deletes the remote copy.
                when (direction) {
                    SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
                    else -> SyncDecision.SKIP
                }
            }
            else -> when {
                deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
                direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
                else -> SyncDecision.SKIP
            }
        }

        localChanged && remoteChanged -> when (direction) {
            SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD
            SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD
            SyncDirection.TWO_WAY -> when (conflictStrategy) {
                ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD
                ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD
                ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModifiedMs ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
                ConflictStrategy.KEEP_LARGEST -> if ((local?.sizeBytes ?: 0L) >= (remote?.sizeBytes ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
                ConflictStrategy.KEEP_BOTH -> SyncDecision.CONFLICT
                ConflictStrategy.ASK -> SyncDecision.CONFLICT
            }
        }

        localChanged -> when (direction) {
            SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
            else -> SyncDecision.SKIP
        }
        remoteChanged -> when (direction) {
            SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
            else -> SyncDecision.SKIP
        }
        else -> SyncDecision.SKIP
    }
}

enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }

/**
 * Heuristic: did a provider call fail because the remote path doesn't exist (HTTP 404)? Providers
 * surface errors as exceptions carrying the HTTP status in the message, so we match on that. Used
 * only to let a first-ever sync proceed against a not-yet-created remote folder (see performSync).
 */
internal fun isRemoteNotFound(e: Throwable): Boolean {
    val m = (e.message ?: "").lowercase()
    return "404" in m || "not found" in m || "notfound" in m
}

/**
 * OS-generated, volatile paths that must NEVER sync on any pair, regardless of user exclude
 * config. These are matched against every path SEGMENT (directory names included), so an entire
 * subtree like "DCIM/.thumbnails/..." is ignored. Android continuously regenerates and evicts
 * its thumbnail cache and shuffles files through .trashed-/.pending- staging dirs; syncing them
 * produces an endless upload→evict→DELETE_REMOTE→regenerate loop. ".sfpart" is our own atomic-
 * write temp suffix and must never be propagated either.
 */
private val ALWAYS_IGNORED_SEGMENTS = setOf(".thumbnails")
private val ALWAYS_IGNORED_PREFIXES = listOf(".trashed-", ".pending-")
private val ALWAYS_IGNORED_SUFFIXES = listOf(".sfpart")

/**
 * App-private storage trees. On Android 11+ (scoped storage) another app's SAF grant can LIST
 * these directories but cannot OPEN the files inside, so every transfer fails and the pair is
 * stuck reporting "Partial" forever (e.g. Android/media/com.whatsapp/...). They hold app-managed
 * data, not user content worth syncing, so they are excluded entirely. Matched case-insensitively
 * against the full relative path so the whole subtree is ignored on both sides.
 */
private val ALWAYS_IGNORED_PATH_PREFIXES = listOf("android/data/", "android/media/", "android/obb/")

/**
 * True if [rel] should be excluded from sync entirely. Applied symmetrically to the local walk,
 * the remote listing, and known state so an excluded path is never uploaded, downloaded, or
 * deleted. The always-ignored rules run on every segment; user-configured rules
 * (skipHiddenFiles, excludePatterns, excludeExtensions) match the filename, mirroring the
 * existing local-walk semantics in LocalAccessor.
 */
internal fun isExcludedPath(rel: String, pair: SyncPair): Boolean {
    val normalized = rel.replace('\\', '/')
    val lower = normalized.lowercase()
    if (ALWAYS_IGNORED_PATH_PREFIXES.any { lower.startsWith(it) }) return true
    val segments = normalized.split('/').filter { it.isNotEmpty() }
    if (segments.isEmpty()) return false
    for (seg in segments) {
        if (seg in ALWAYS_IGNORED_SEGMENTS) return true
        if (ALWAYS_IGNORED_PREFIXES.any { seg.startsWith(it) }) return true
        if (ALWAYS_IGNORED_SUFFIXES.any { seg.endsWith(it) }) return true
    }
    val fileName = segments.last()
    if (pair.skipHiddenFiles && fileName.startsWith('.')) return true
    if (pair.excludePatterns.any { pat -> fileName.matches(globToRegex(pat)) }) return true
    val ext = fileName.substringAfterLast('.', "").lowercase()
    if (pair.excludeExtensions.any { ext == it.lowercase().trimStart('.') }) return true
    return false
}

/**
 * True if a relative sync path is unsafe to act on — empty, absolute, or containing a ".."
 * segment that would let a hostile remote escape the sync root via path traversal. Applied to
 * every path before any file operation as defense-in-depth (WebDAV already filters names at the
 * parser; SFTP and any future provider are covered here).
 */
internal fun isUnsafeSyncPath(rel: String): Boolean {
    if (rel.isBlank()) return true
    val normalized = rel.replace('\\', '/')
    if (normalized.startsWith("/")) return true
    return normalized.split('/').any { it == ".." }
}

data class SyncResult(
    val uploaded: Int = 0,
    val downloaded: Int = 0,
    val deleted: Int = 0,
    val skipped: Int = 0,
    val failedFiles: Int = 0,
    val conflicts: Int = 0,
    val bytesTransferred: Long = 0L,
    val error: Exception? = null,
)
