package com.github.nullptroma.wallenc.domain.vault.storages.yandex

import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException
import com.github.nullptroma.wallenc.domain.vault.storages.common.StorageSyncJournalBuffer
import com.github.nullptroma.wallenc.domain.vault.storages.common.StorageSyncJournalCodec
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.ResourceDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.YandexVaultPersistedStats
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
import com.github.nullptroma.wallenc.domain.common.impl.CommonFile
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournal
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.util.UUID
import kotlin.jvm.Volatile

/**
 * Реализация [IStorageAccessor] для дерева файлов `app:/<storageUuid>/…` на Яндекс.Диске.
 *
 * [isAvailable] объединяет доступность удалённого хранилища ([vaultAvailability])
 * и успешную локальную инициализацию ([storageReady]) этого аксессора.
 */
class YandexStorageAccessor(
    private val storageUuid: UUID,
    private val repo: YandexDiskRepository,
    private val ioDispatcher: CoroutineDispatcher,
    vaultAvailability: StateFlow<Boolean>,
    private val accessorScope: CoroutineScope,
    private val reportAuthFailure: () -> Unit,
) : IStorageAccessor {
    private val syncActorId = UUID.randomUUID().toString()
    private val syncLockMutex = Mutex()

    private val diskRoot = "app:/$storageUuid"

    private val _size = MutableStateFlow<Long?>(null)
    override val size: StateFlow<Long?> = _size

    private val _numberOfFiles = MutableStateFlow<Int?>(null)
    override val numberOfFiles: StateFlow<Int?> = _numberOfFiles

    private val _storageReady = MutableStateFlow(false)

    override val isAvailable: StateFlow<Boolean> =
        combine(vaultAvailability, _storageReady) { vaultOk, ready -> vaultOk && ready }
            .stateIn(accessorScope, SharingStarted.Eagerly, false)

    private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>()
    override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates

    private val _dirsUpdates = MutableSharedFlow<DataPage<IDirectory>>()
    override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = _dirsUpdates

    private var statsPersistJob: Job? = null

    private val journalBuffer = StorageSyncJournalBuffer(
        syncActorId = syncActorId,
        originStorageUuid = storageUuid,
        readJournal = { readSyncJournalUnchecked() },
        writeJournal = { writeSyncJournal(it) },
    )

    @Volatile
    private var systemDirEnsured: Boolean = false

    suspend fun init() = withContext(ioDispatcher) {
        try {
            val persisted = readPersistedStats()
            if (persisted != null) {
                _size.value = persisted.totalBytes
                _numberOfFiles.value = persisted.fileCount
            } else {
                try {
                    scanSizeAndNumOfFiles()
                    writePersistedStatsInternal()
                } catch (e: CancellationException) {
                    throw e
                } catch (_: Exception) {
                    // Полный обход дерева не обязателен для работы; при таймауте сети storage остаётся доступным.
                }
            }
            _storageReady.value = true
        } catch (e: YandexDiskAuthException) {
            reportAuthFailure()
            _storageReady.value = false
            throw WallencException.Auth.Failed()
        } catch (e: Exception) {
            _storageReady.value = false
            throw e.toVaultWallencException()
        }
    }

    private inline fun <T> guard(block: () -> T): T {
        try {
            return block()
        } catch (e: CancellationException) {
            throw e
        } catch (e: YandexDiskAuthException) {
            reportAuthFailure()
            throw WallencException.Auth.Failed()
        } catch (e: Throwable) {
            throw e.toVaultWallencException()
        }
    }

    private fun toDiskPath(rel: String): String {
        val tail = when {
            rel.isBlank() || rel == "/" -> ""
            else -> rel.trimStart('/')
        }
        // Корень хранилища — каталог в app:; для list/get API стабильнее с завершающим «/».
        return if (tail.isEmpty()) "$diskRoot/" else "$diskRoot/$tail"
    }

    private fun toRelPath(diskPath: String): String {
        val u = storageUuid.toString()
        val app = "app:/$u/"
        if (diskPath.startsWith(app)) {
            val tail = diskPath.removePrefix(app).removeSuffix("/")
            return if (tail.isEmpty()) "/" else "/$tail"
        }
        val needle = "/$u/"
        val i = diskPath.indexOf(needle)
        if (i >= 0) {
            val tail = diskPath.substring(i + needle.length).removeSuffix("/")
            return if (tail.isEmpty()) "/" else "/$tail"
        }
        val needleEnd = "/$u"
        if (diskPath.endsWith(needleEnd, ignoreCase = true)) {
            return "/"
        }
        return "/"
    }

    private fun isSystemDiskPath(diskPath: String?): Boolean {
        if (diskPath == null) return false
        return diskPath.contains("/$SYSTEM_HIDDEN_DIRNAME/") ||
            diskPath.endsWith("/$SYSTEM_HIDDEN_DIRNAME")
    }

    private fun isSystemRel(rel: String): Boolean =
        rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/")

    private suspend fun ensureSystemDirExists() {
        if (systemDirEnsured) return
        val p = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME")
        if (guard { repo.getOrNull(p) }?.type == "dir") {
            systemDirEnsured = true
            return
        }
        guard { repo.createFolder(p) }
        systemDirEnsured = true
    }

    private fun statsDiskPath(): String = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME")

    private suspend fun readPersistedStats(): YandexVaultPersistedStats? {
        val meta = guard { repo.getOrNull(statsDiskPath()) } ?: return null
        if (meta.type != "file") return null
        return try {
            guard {
                repo.openDownloadStream(statsDiskPath()).use {
                    statsMapper.readValue(it, YandexVaultPersistedStats::class.java)
                }
            }
        } catch (_: Exception) {
            null
        }
    }

    private suspend fun writePersistedStatsInternal() {
        ensureSystemDirExists()
        val bytes = statsMapper.writeValueAsBytes(
            YandexVaultPersistedStats(
                totalBytes = _size.value ?: 0L,
                fileCount = _numberOfFiles.value ?: 0,
            ),
        )
        guard { repo.uploadBytes(statsDiskPath(), bytes, overwrite = true) }
    }

    private suspend fun persistStatsImmediate() {
        statsPersistJob?.cancel()
        statsPersistJob = null
        writePersistedStatsInternal()
    }

    private fun scheduleStatsPersist() {
        statsPersistJob?.cancel()
        statsPersistJob = accessorScope.launch(ioDispatcher) {
            delay(STATS_DEBOUNCE_MS)
            try {
                writePersistedStatsInternal()
            } catch (e: CancellationException) {
                throw e
            } catch (e: YandexDiskAuthException) {
                reportAuthFailure()
                throw e
            } catch (_: Exception) {
                // Запись stats — best-effort; сетевые сбои не роняем процесс.
            }
        }
    }

    /**
     * Сумма размеров и число пользовательских файлов в поддереве [relDir] (сама папка не считается файлом).
     */
    private suspend fun sumSubtreeStats(relDir: String): Pair<Int, Long> {
        var fileCount = 0
        var totalBytes = 0L
        val queue = ArrayDeque<String>()
        queue.add(relDir)
        while (queue.isNotEmpty()) {
            coroutineContext.ensureActive()
            val rel = queue.removeFirst()
            if (isSystemRel(rel)) continue
            val (files, dirs) = listImmediateChildren(rel)
            for (d in dirs) {
                if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
            }
            for (f in files) {
                if (!isSystemRel(f.metaInfo.path)) {
                    fileCount++
                    totalBytes += f.metaInfo.size
                }
            }
        }
        return fileCount to totalBytes
    }

    private suspend fun scanSizeAndNumOfFiles() {
        var totalSize = 0L
        var count = 0
        val queue = ArrayDeque<String>()
        queue.add("/")
        while (queue.isNotEmpty()) {
            val rel = queue.removeFirst()
            if (isSystemRel(rel)) continue
            val (files, dirs) = listImmediateChildren(rel)
            for (d in dirs) {
                if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
            }
            for (f in files) {
                if (!isSystemRel(f.metaInfo.path)) {
                    totalSize += f.metaInfo.size
                    count++
                }
            }
        }
        _size.value = totalSize
        _numberOfFiles.value = count
    }

    private suspend fun listImmediateChildren(relDir: String): Pair<List<IFile>, List<IDirectory>> {
        val diskPath = toDiskPath(relDir)
        val files = mutableListOf<IFile>()
        val dirs = mutableListOf<IDirectory>()
        var offset = 0
        while (true) {
            coroutineContext.ensureActive()
            val res = guard { repo.list(diskPath, API_LIST_LIMIT, offset) }
            val items = res.embedded?.items.orEmpty()
            for (it in items) {
                if (isSystemDiskPath(it.path)) continue
                val rel = toRelPath(it.path ?: continue)
                if (isSystemRel(rel)) continue
                when (it.type) {
                    "file" -> files.add(it.toCommonFile(rel))
                    "dir" -> dirs.add(it.toCommonDir(rel))
                }
            }
            if (items.size < API_LIST_LIMIT) break
            offset += items.size
        }
        return files to dirs
    }

    private suspend fun getMetadataAfterWrite(diskPath: String): ResourceDto {
        val maxAttempts = 3
        repeat(maxAttempts) { attempt ->
            try {
                return guard { repo.get(diskPath) }
            } catch (e: FileNotFoundException) {
                if (attempt == maxAttempts - 1) throw e
                delay(200L * (attempt + 1))
            }
        }
        error("unreachable")
    }

    private fun ResourceDto.toCommonFile(rel: String): CommonFile =
        CommonFile(
            CommonMetaInfo(
                size = size ?: 0L,
                isDeleted = boolProp(customProperties, PROP_DELETED),
                isHidden = boolProp(customProperties, PROP_HIDDEN),
                lastModified = modified ?: created ?: Instant.EPOCH,
                path = rel,
            ),
        )

    private fun ResourceDto.toCommonDir(rel: String): CommonDirectory =
        CommonDirectory(
            CommonMetaInfo(
                size = 0L,
                isDeleted = boolProp(customProperties, PROP_DELETED),
                isHidden = boolProp(customProperties, PROP_HIDDEN),
                lastModified = modified ?: created ?: Instant.EPOCH,
                path = rel,
            ),
            elementsCount = null,
        )

    private fun boolProp(props: Map<String, Any?>?, key: String): Boolean {
        val v = props?.get(key) ?: return false
        return when (v) {
            is Boolean -> v
            is String -> v.equals("true", ignoreCase = true)
            else -> v.toString().equals("true", ignoreCase = true)
        }
    }

    override suspend fun getAllFiles(): List<IFile> = withContext(ioDispatcher) {
        val out = mutableListOf<IFile>()
        val queue = ArrayDeque<String>()
        queue.add("/")
        while (queue.isNotEmpty()) {
            val rel = queue.removeFirst()
            if (isSystemRel(rel)) continue
            val (files, dirs) = listImmediateChildren(rel)
            out.addAll(files.filter { !isSystemRel(it.metaInfo.path) })
            for (d in dirs) {
                if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
            }
        }
        out
    }

    override suspend fun getFiles(path: String): List<IFile> = withContext(ioDispatcher) {
        listImmediateChildren(path).first
    }

    override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow {
        val all = try {
            withContext(ioDispatcher) { listImmediateChildren(path).first }
        } catch (e: CancellationException) {
            throw e
        } catch (_: Exception) {
            emit(filesFlowErrorPage())
            return@flow
        }
        emitAllFilesPages(all)
    }.flowOn(ioDispatcher)

    override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) {
        val out = mutableListOf<IDirectory>()
        val queue = ArrayDeque<String>()
        queue.add("/")
        while (queue.isNotEmpty()) {
            val rel = queue.removeFirst()
            if (isSystemRel(rel)) continue
            val (_, dirs) = listImmediateChildren(rel)
            for (d in dirs) {
                if (!isSystemRel(d.metaInfo.path)) {
                    out.add(d)
                    queue.add(d.metaInfo.path)
                }
            }
        }
        out
    }

    override suspend fun getDirs(path: String): List<IDirectory> = withContext(ioDispatcher) {
        listImmediateChildren(path).second
    }

    override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow {
        val all = try {
            withContext(ioDispatcher) { listImmediateChildren(path).second }
        } catch (e: CancellationException) {
            throw e
        } catch (_: Exception) {
            emit(dirsFlowErrorPage())
            return@flow
        }
        emitAllDirsPages(all)
    }.flowOn(ioDispatcher)

    override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) {
        val r = guard { repo.get(toDiskPath(path)) }
        if (r.type != "file") throw WallencException.Storage.NotAFile()
        r.toCommonFile(path)
    }

    override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) {
        val r = guard { repo.get(toDiskPath(path)) }
        if (r.type != "dir") throw WallencException.Storage.NotADirectory()
        r.toCommonDir(path)
    }

    override suspend fun setHidden(path: String, hidden: Boolean) = withContext(ioDispatcher) {
        patchCustomProps(
            path,
            mapOf(PROP_HIDDEN to if (hidden) "true" else "false"),
        )
        val f = getFileInfo(path)
        _filesUpdates.emit(
            DataPage(listOf(f), pageLength = 1, pageIndex = 0),
        )
    }

    override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
        touchParentDirs(path)
        var created = false
        try {
            guard { repo.uploadBytes(toDiskPath(path), ByteArray(0), overwrite = false) }
            created = true
        } catch (_: Exception) {
            // файл уже есть — ок
        }
        if (created) {
            _numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1
            persistStatsImmediate()
            appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
        }
    }

    override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
        val segments = pathSegments(path)
        var acc = ""
        for (seg in segments) {
            acc += "/$seg"
            val diskPath = toDiskPath(acc)
            when (guard { repo.getOrNull(diskPath) }?.type) {
                "dir" -> continue
                "file" -> throw WallencException.Storage.PathIsFile()
                else -> guard { repo.createFolder(diskPath) }
            }
        }
    }

    override suspend fun delete(path: String, recordSyncJournal: Boolean) = withContext(ioDispatcher) {
        if (path == "/" || path.isBlank()) {
            throw WallencException.Storage.DeleteRootForbidden()
        }
        val diskPath = toDiskPath(path)
        val prior = guard { repo.getOrNull(diskPath) }
        if (prior != null) {
            when (prior.type) {
                "file" -> {
                    val sz = prior.size ?: 0L
                    _size.value = ((_size.value ?: 0L) - sz).coerceAtLeast(0L)
                    _numberOfFiles.value = ((_numberOfFiles.value ?: 0) - 1).coerceAtLeast(0)
                }
                "dir" -> {
                    val (fc, total) = sumSubtreeStats(path)
                    _size.value = ((_size.value ?: 0L) - total).coerceAtLeast(0L)
                    _numberOfFiles.value = ((_numberOfFiles.value ?: 0) - fc).coerceAtLeast(0)
                }
            }
        }
        guard { repo.delete(diskPath, permanently = true) }
        scheduleStatsPersist()
        if (recordSyncJournal) {
            appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
        }
    }

    override suspend fun openWrite(path: String, recordSyncJournal: Boolean): OutputStream = withContext(ioDispatcher) {
        touchParentDirs(path)
        val tmp = File.createTempFile("wallenc-yandex-", ".upload")
        val fos = FileOutputStream(tmp)
        fos.onClosed {
            runCommitAfterStreamClosed {
                commitUploadedFile(path, tmp, recordSyncJournal)
            }
        }
    }

    override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
        guard { repo.openDownloadStream(toDiskPath(path)) }
    }

    override suspend fun moveToTrash(path: String, recordSyncJournal: Boolean) = withContext(ioDispatcher) {
        patchCustomProps(path, mapOf(PROP_DELETED to "true"))
        if (recordSyncJournal) {
            appendSyncEntry(path = path, operation = StorageSyncOperation.TRASH)
        }
    }

    override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
        ensureSystemDirExists()
        val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
        val diskPath = toDiskPath(rel)
        when (guard { repo.getOrNull(diskPath) }?.type) {
            "file" -> guard { repo.openDownloadStream(diskPath) }
            null -> throw WallencException.Storage.FileNotFound()
            else -> throw WallencException.Storage.FileNotFound()
        }
    }

    override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) {
        ensureSystemDirExists()
        val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
        val uploadBuffer = ByteArrayOutputStream()
        uploadBuffer.onClosed {
            val bytes = uploadBuffer.toByteArray()
            val diskPath = toDiskPath(rel)
            runCommitAfterStreamClosed {
                guard { repo.uploadBytes(diskPath, bytes, overwrite = true) }
            }
        }
    }

    override suspend fun readSyncJournal(): StorageSyncJournal = withContext(ioDispatcher) {
        journalBuffer.flushPending()
        readSyncJournalUnchecked()
    }

    override suspend fun flushPendingSyncJournal() = withContext(ioDispatcher) {
        journalBuffer.flushPending()
    }

    override suspend fun putSyncJournalEntries(entries: StorageSyncJournal) = withContext(ioDispatcher) {
        journalBuffer.putEntries(entries)
    }

    private suspend fun readSyncJournalUnchecked(): StorageSyncJournal {
        val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
        return StorageSyncJournalCodec.read(statsMapper, bytes)
    }

    private suspend fun writeSyncJournal(journal: StorageSyncJournal) {
        openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out ->
            statsMapper.writeValue(out, journal)
        }
    }

    override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
        val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
        if (bytes.isEmpty()) {
            return@withContext null
        }
        return@withContext runCatching {
            statsMapper.readValue(bytes, StorageSyncLock::class.java)
        }.getOrNull()
    }

    /**
     * Best-effort lock на Диске (read-modify-write без CAS на стороне API).
     * Межустройственная координация опирается на [StorageSyncEngine] mutex в процессе.
     */
    override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = withContext(ioDispatcher) {
        repeat(SYNC_LOCK_CAS_RETRIES) { attempt ->
            val acquired = tryAcquireSyncLockOnce(holderId, leaseUntil)
            if (acquired) {
                return@withContext true
            }
            if (attempt < SYNC_LOCK_CAS_RETRIES - 1) {
                delay(SYNC_LOCK_CAS_DELAY_MS)
            }
        }
        return@withContext false
    }

    private suspend fun tryAcquireSyncLockOnce(holderId: String, leaseUntil: Instant): Boolean {
        return syncLockMutex.withLock {
            val current = readSyncLock()
            val now = Instant.now()
            val foreignLockActive = current != null &&
                current.holderId != holderId &&
                current.leaseUntil.isAfter(now) &&
                current.updatedAt.plusSeconds(SYNC_LOCK_STALE_TIMEOUT_SECONDS).isAfter(now)
            if (foreignLockActive) {
                return@withLock false
            }
            val next = StorageSyncLock(
                holderId = holderId,
                leaseUntil = leaseUntil,
                updatedAt = now,
            )
            openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
                statsMapper.writeValue(out, next)
            }
            readSyncLock()?.holderId == holderId
        }
    }

    override suspend fun releaseSyncLock(holderId: String) = withContext(ioDispatcher) {
        syncLockMutex.withLock {
            val current = readSyncLock() ?: return@withLock
            if (current.holderId != holderId) {
                return@withLock
            }
            openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
                out.write(ByteArray(0))
            }
        }
    }

    override suspend fun forceClearSyncLock() = withContext(ioDispatcher) {
        syncLockMutex.withLock {
            openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
                out.write(ByteArray(0))
            }
        }
    }

    /**
     * Выполняется из [OutputStream.close]; ошибки upload пробрасываются вызывающему коду.
     */
    private fun runCommitAfterStreamClosed(block: suspend () -> Unit) {
        runBlocking {
            withContext(ioDispatcher) {
                block()
            }
        }
    }

    private suspend fun commitUploadedFile(path: String, tmp: File, recordSyncJournal: Boolean) {
        try {
            val diskPath = toDiskPath(path)
            val prior = guard { repo.getOrNull(diskPath) }
            if (prior?.type == "dir") {
                throw WallencException.Storage.CannotWriteOverDirectory()
            }
            val hadFile = prior?.type == "file"
            val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L
            guard { repo.uploadFile(diskPath, tmp, overwrite = true) }
            val after = guard { getMetadataAfterWrite(diskPath) }
            if (after.type != "file") {
                throw WallencException.Storage.UnexpectedState()
            }
            val newSize = after.size ?: 0L
            _size.value = ((_size.value ?: 0L) + newSize - priorSize).coerceAtLeast(0L)
            if (!hadFile) {
                _numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1
            }
            persistStatsImmediate()
            val info = runCatching { after.toCommonFile(path) }.getOrNull()
            info?.let {
                _filesUpdates.emit(DataPage(listOf(it), pageLength = 1, pageIndex = 0))
            }
            if (recordSyncJournal) {
                appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
            }
        } finally {
            tmp.delete()
        }
    }

    private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
        val cleanedPath = StorageSyncPaths.normalize(path)
        if (!StorageSyncPaths.isSyncableUserPath(cleanedPath)) {
            return
        }
        val sequence = journalBuffer.nextSequence()
        val entry = journalBuffer.buildEntry(cleanedPath, operation, sequence)
        journalBuffer.appendEntry(cleanedPath, entry)
    }

    private suspend fun touchParentDirs(path: String) {
        val normalized = path.removeSuffix("/")
        if (normalized == "/" || normalized.isBlank()) return
        val lastSlash = normalized.lastIndexOf('/')
        if (lastSlash <= 0) return
        val parent = normalized.substring(0, lastSlash).ifEmpty { "/" }
        touchDir(parent)
    }

    private fun pathSegments(path: String): List<String> =
        path.trim('/').split('/').filter { it.isNotBlank() }

    /** PATCH только переданных ключей — API Диска дополняет [custom_properties], без предварительного GET. */
    private suspend fun patchCustomProps(path: String, props: Map<String, String>) {
        guard { repo.setCustomProperties(toDiskPath(path), props) }
    }

    private suspend fun FlowCollector<DataPage<IFile>>.emitAllFilesPages(all: List<IFile>) {
        var pageIndex = 0
        var i = 0
        while (i < all.size) {
            val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
            emit(
                DataPage(
                    list = chunk,
                    isLoading = false,
                    isError = false,
                    hasNext = i + DATA_PAGE_LENGTH < all.size,
                    pageLength = DATA_PAGE_LENGTH,
                    pageIndex = pageIndex++,
                ),
            )
            i += DATA_PAGE_LENGTH
        }
        if (all.isEmpty()) {
            emit(
                DataPage(
                    list = emptyList(),
                    isLoading = false,
                    isError = false,
                    hasNext = false,
                    pageLength = DATA_PAGE_LENGTH,
                    pageIndex = 0,
                ),
            )
        }
    }

    private suspend fun FlowCollector<DataPage<IDirectory>>.emitAllDirsPages(all: List<IDirectory>) {
        var pageIndex = 0
        var i = 0
        while (i < all.size) {
            val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
            emit(
                DataPage(
                    list = chunk,
                    isLoading = false,
                    isError = false,
                    hasNext = i + DATA_PAGE_LENGTH < all.size,
                    pageLength = DATA_PAGE_LENGTH,
                    pageIndex = pageIndex++,
                ),
            )
            i += DATA_PAGE_LENGTH
        }
        if (all.isEmpty()) {
            emit(
                DataPage(
                    list = emptyList(),
                    isLoading = false,
                    isError = false,
                    hasNext = false,
                    pageLength = DATA_PAGE_LENGTH,
                    pageIndex = 0,
                ),
            )
        }
    }

    private fun filesFlowErrorPage(): DataPage<IFile> =
        DataPage(
            list = emptyList(),
            isLoading = false,
            isError = true,
            hasNext = false,
            pageLength = DATA_PAGE_LENGTH,
            pageIndex = 0,
        )

    private fun dirsFlowErrorPage(): DataPage<IDirectory> =
        DataPage(
            list = emptyList(),
            isLoading = false,
            isError = true,
            hasNext = false,
            pageLength = DATA_PAGE_LENGTH,
            pageIndex = 0,
        )

    companion object {
        private val statsMapper = jacksonObjectMapper().apply { findAndRegisterModules() }

        private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
        private const val STATS_FILENAME = "yandex-vault-stats.json"
        private const val SYNC_JOURNAL_FILENAME = "sync-journal.json"
        private const val SYNC_LOCK_FILENAME = "sync-lock.json"
        private const val SYNC_LOCK_STALE_TIMEOUT_SECONDS: Long = 60 * 60
        private const val SYNC_LOCK_CAS_RETRIES = 3
        private const val SYNC_LOCK_CAS_DELAY_MS = 80L
        private const val STATS_DEBOUNCE_MS = 450L
        private const val DATA_PAGE_LENGTH = 10
        private const val API_LIST_LIMIT = 1000
        private const val PROP_HIDDEN = "wallenc.hidden"
        private const val PROP_DELETED = "wallenc.deleted"
    }
}
