package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskApi
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.EmbeddedResourceListDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.ResourceDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
import retrofit2.HttpException
import retrofit2.Response
import java.io.FileNotFoundException
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong

class YandexDiskRepository(
    private val api: YandexDiskApi,
    private val rawHttp: okhttp3.OkHttpClient,
    private val ioDispatcher: CoroutineDispatcher,
) {

    private val diskCacheLock = Any()
    private var diskInfoCached: DiskInfoDto? = null
    private var diskInfoCachedUntilMs: Long = 0L

    private val listCache = ConcurrentHashMap<ListCacheKey, ResourceDto>()
    private val getCache = ConcurrentHashMap<String, ResourceDto>()
    private val cloudApiCallCount = AtomicLong(0)

    fun cloudApiCallCount(): Long = cloudApiCallCount.get()

    fun resetCloudApiCallCount() {
        cloudApiCallCount.set(0)
    }

    suspend fun diskInfo(): DiskInfoDto = withContext(ioDispatcher) {
        val now = System.currentTimeMillis()
        synchronized(diskCacheLock) {
            val cached = diskInfoCached
            if (cached != null && now < diskInfoCachedUntilMs) {
                return@withContext cached
            }
        }
        val fresh = wrapAuth { api.getDisk() }
        synchronized(diskCacheLock) {
            diskInfoCached = fresh
            diskInfoCachedUntilMs = System.currentTimeMillis() + DISK_INFO_TTL_MS
        }
        fresh
    }

    suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto =
        withContext(ioDispatcher) {
            val key = ListCacheKey(path = path, limit = limit, offset = offset, sort = sort)
            listCache[key]?.let { return@withContext it }

            suspend fun tryList(p: String): ResourceDto? =
                try {
                    wrapAuth { api.listResources(p, limit, offset, sort, FIELDS_LIST) }
                } catch (e: HttpException) {
                    if (e.code() == 404) null else throw e
                }
            val primary = tryList(path)
            val secondary = if (!path.endsWith('/') && path != "app:/") tryList("$path/") else null
            val result = primary ?: secondary
                ?: ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList()))
            putListCache(key, result)
            result
        }

    suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) {
        getCache[path]?.let { return@withContext it }
        val result = fetchResource(path)
        putGetCache(path, result)
        result
    }

    suspend fun getOrNull(path: String): ResourceDto? = withContext(ioDispatcher) {
        getCache[path]?.let { return@withContext it }
        val result = fetchResourceOrNull(path)
        if (result != null) {
            putGetCache(path, result)
        }
        result
    }

    suspend fun createFolder(path: String): Unit = withContext(ioDispatcher) {
        val resp = wrapAuth { api.createFolder(path) }
        when (resp.code()) {
            201, 409 -> invalidateDiskMetaCaches(path)
            else -> throw failure("createFolder", resp)
        }
    }

    suspend fun delete(path: String, permanently: Boolean = true): Unit = withContext(ioDispatcher) {
        val resp = wrapAuth { api.deleteResource(path, permanently) }
        when (resp.code()) {
            204 -> invalidateDiskMetaCaches(path)
            202 -> {
                val link = resp.body()?.use { body -> parseLink(body) }
                    ?: throw IOException("DELETE 202 without body")
                awaitOperation(link.href)
                invalidateDiskMetaCaches(path)
            }
            404 -> invalidateDiskMetaCaches(path)
            else -> throw failure("delete", resp)
        }
    }

    suspend fun setCustomProperties(path: String, props: Map<String, String>): Unit =
        withContext(ioDispatcher) {
            val resp = wrapAuth {
                api.patchResource(path, CustomPropertiesPatchDto(props))
            }
            if (!resp.isSuccessful) {
                throw failure("patch", resp)
            }
            resp.body()?.close()
            invalidateDiskMetaCaches(path)
        }

    suspend fun uploadBytes(path: String, bytes: ByteArray, overwrite: Boolean = true): Unit =
        withContext(ioDispatcher) {
            val link = uploadLinkOrThrow(path, overwrite)
            require(link.method.equals("PUT", ignoreCase = true)) {
                "Unexpected upload method ${link.method}"
            }
            val body = bytes.toRequestBody(OCTET_STREAM)
            val req = Request.Builder().url(link.href).put(body).build()
            repeat(LOCKED_RETRY_MAX) { attempt ->
                recordCloudApiCall()
                rawHttp.newCall(req).execute().use { resp ->
                    when {
                        resp.isSuccessful -> {
                            invalidateDiskMetaCaches(path)
                            return@withContext
                        }
                        resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 ->
                            delay(lockedBackoffMs(attempt))
                        else ->
                            throw IOException("Upload failed: HTTP ${resp.code}")
                    }
                }
            }
        }

    suspend fun uploadFile(path: String, file: java.io.File, overwrite: Boolean = true): Unit =
        withContext(ioDispatcher) {
            val link = uploadLinkOrThrow(path, overwrite)
            require(link.method.equals("PUT", ignoreCase = true)) {
                "Unexpected upload method ${link.method}"
            }
            val body = file.asRequestBody(OCTET_STREAM)
            val req = Request.Builder().url(link.href).put(body).build()
            repeat(LOCKED_RETRY_MAX) { attempt ->
                recordCloudApiCall()
                rawHttp.newCall(req).execute().use { resp ->
                    when {
                        resp.isSuccessful -> {
                            invalidateDiskMetaCaches(path)
                            return@withContext
                        }
                        resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 ->
                            delay(lockedBackoffMs(attempt))
                        else ->
                            throw IOException("Upload failed: HTTP ${resp.code}")
                    }
                }
            }
        }

    /** Поток должен быть закрыт вызывающим кодом — закроет HTTP-ответ. */
    suspend fun openDownloadStream(path: String): InputStream = withContext(ioDispatcher) {
        val link = try {
            wrapAuth { api.getDownloadLink(path) }
        } catch (e: HttpException) {
            if (e.code() == 404) throw FileNotFoundException(path)
            throw e
        }
        require(link.method.equals("GET", ignoreCase = true)) {
            "Unexpected download method ${link.method}"
        }
        val req = Request.Builder().url(link.href).get().build()
        repeat(LOCKED_RETRY_MAX) { attempt ->
            recordCloudApiCall()
            val resp = rawHttp.newCall(req).execute()
            when {
                resp.isSuccessful -> {
                    val body = resp.body
                    val stream = body.byteStream()
                    return@withContext object : FilterInputStream(stream) {
                        override fun close() {
                            `in`.use {
                                // Response must be closed after the wrapped stream.
                            }
                            resp.close()
                        }
                    }
                }
                resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 -> {
                    resp.close()
                    delay(lockedBackoffMs(attempt))
                }
                else -> {
                    val code = resp.code
                    resp.close()
                    throw IOException("Download failed: HTTP $code")
                }
            }
        }
        throw IOException("Download failed: HTTP 423 after retries")
    }

    private suspend fun awaitOperation(href: String) {
        repeat(OPERATION_POLL_MAX) {
            delay(OPERATION_POLL_DELAY_MS)
            val st: OperationStatusDto = try {
                wrapAuth { api.getOperationByUrl(href) }
            } catch (e: HttpException) {
                if (e.code() == 404) {
                    throw IOException("Disk async operation status not found (404)")
                }
                throw e
            }
            when (st.status?.lowercase()) {
                "success" -> return
                "failure", "failed" -> throw IOException("Disk async operation failed")
                else -> { }
            }
        }
        throw IOException("Disk async operation timed out")
    }

    private suspend fun uploadLinkOrThrow(path: String, overwrite: Boolean): LinkDto {
        try {
            return wrapAuth { api.getUploadLink(path, overwrite) }
        } catch (e: HttpException) {
            if (e.code() == 404) {
                throw FileNotFoundException("Upload path or parent not found: $path")
            }
            throw e
        }
    }

    private suspend fun fetchResource(path: String): ResourceDto {
        suspend fun tryGet(p: String): ResourceDto? =
            try {
                wrapAuth { api.getResource(p, FIELDS_RESOURCE) }
            } catch (e: HttpException) {
                if (e.code() == 404) null else throw e
            }
        val primary = tryGet(path)
        val secondary = if (!path.endsWith('/')) tryGet("$path/") else null
        return primary ?: secondary ?: throw FileNotFoundException(path)
    }

    private suspend fun fetchResourceOrNull(path: String): ResourceDto? {
        suspend fun tryGet(p: String): ResourceDto? =
            try {
                wrapAuth { api.getResource(p, FIELDS_RESOURCE) }
            } catch (e: HttpException) {
                if (e.code() == 404) null else throw e
            }
        return tryGet(path) ?: if (!path.endsWith('/')) tryGet("$path/") else null
    }

    private fun putListCache(key: ListCacheKey, value: ResourceDto) {
        if (listCache.size >= LIST_CACHE_MAX_ENTRIES) {
            listCache.clear()
        }
        listCache[key] = value
    }

    private fun putGetCache(path: String, value: ResourceDto) {
        if (getCache.size >= GET_CACHE_MAX_ENTRIES) {
            getCache.clear()
        }
        getCache[path] = value
    }

    private fun invalidateDiskMetaCaches(changedDiskPath: String? = null) {
        synchronized(diskCacheLock) {
            diskInfoCached = null
            diskInfoCachedUntilMs = 0L
        }
        if (changedDiskPath == null) {
            listCache.clear()
            getCache.clear()
            return
        }
        val prefixes = cachePrefixesForPath(changedDiskPath)
        listCache.keys.removeAll { key ->
            prefixes.any { prefix ->
                key.path.startsWith(prefix) || prefix.startsWith(key.path.trimEnd('/'))
            }
        }
        getCache.keys.removeAll { cachedPath ->
            prefixes.any { prefix ->
                cachedPath.startsWith(prefix) || prefix.startsWith(cachedPath.trimEnd('/'))
            }
        }
    }

    private fun cachePrefixesForPath(diskPath: String): List<String> {
        val normalized = diskPath.trimEnd('/')
        val out = mutableListOf<String>()
        var current = normalized
        while (current.isNotEmpty()) {
            out.add(current)
            out.add("$current/")
            val slash = current.lastIndexOf('/')
            if (slash <= 0) break
            current = current.substring(0, slash)
        }
        return out
    }

    private suspend inline fun <T> wrapAuth(crossinline block: suspend () -> T): T {
        repeat(LOCKED_RETRY_MAX) { attempt ->
            try {
                recordCloudApiCall()
                return block()
            } catch (e: HttpException) {
                when (e.code()) {
                    401 -> throw YandexDiskAuthException(e.message())
                    423 -> {
                        if (attempt >= LOCKED_RETRY_MAX - 1) {
                            throw IOException(
                                "Yandex Disk: ресурс временно заблокирован (HTTP 423). Повторите позже.",
                                e,
                            )
                        }
                        delay(lockedBackoffMs(attempt))
                    }
                    else -> throw e
                }
            }
        }
        error("unreachable")
    }

    private fun recordCloudApiCall() {
        cloudApiCallCount.incrementAndGet()
    }

    private fun failure(op: String, resp: Response<ResponseBody>): IOException {
        val msg = resp.errorBody()?.string() ?: resp.message()
        return IOException("$op failed: HTTP ${resp.code()} $msg")
    }

    private fun parseLink(body: ResponseBody): LinkDto =
        jackson.readValue(body.string())

    private data class ListCacheKey(
        val path: String,
        val limit: Int,
        val offset: Int,
        val sort: String?,
    )

    companion object {
        private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
        private val OCTET_STREAM = "application/octet-stream".toMediaType()
        private const val OPERATION_POLL_DELAY_MS = 300L
        private const val OPERATION_POLL_MAX = 200

        private const val DISK_INFO_TTL_MS = 45_000L
        private const val LIST_CACHE_MAX_ENTRIES = 384
        private const val GET_CACHE_MAX_ENTRIES = 384

        /** Сколько раз повторять запрос при HTTP 423 (ресурс временно заблокирован). */
        private const val LOCKED_RETRY_MAX = 6

        private fun lockedBackoffMs(attempt: Int): Long =
            (100L * (1L shl attempt)).coerceAtMost(2000L)

        /**
         * Урезанный набор полей для листинга каталога (см. параметр `fields` в Disk API).
         */
        private const val FIELDS_LIST =
            "path,type,name,size,modified,created,custom_properties," +
                "_embedded.items.path,_embedded.items.type,_embedded.items.name," +
                "_embedded.items.size,_embedded.items.modified,_embedded.items.created," +
                "_embedded.items.custom_properties"

        private const val FIELDS_RESOURCE =
            "path,type,name,size,modified,created,custom_properties"
    }
}
