package com.syncflow.data.providers.webdav

import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit

open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {

    protected open val baseUrl: String
        get() = account.serverUrl?.trimEnd('/') ?: ""

    protected val client: OkHttpClient by lazy {
        val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
        val user = creds["username"]?.jsonPrimitive?.content ?: ""
        val pass = creds["password"]?.jsonPrimitive?.content ?: ""
        OkHttpClient.Builder()
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(5, TimeUnit.MINUTES)
            .writeTimeout(5, TimeUnit.MINUTES)
            .addInterceptor { chain ->
                val req = chain.request().newBuilder()
                    .header("Authorization", Credentials.basic(user, pass))
                    .build()
                val resp = chain.proceed(req)
                // Follow redirects for WebDAV methods (OkHttp skips non-GET/HEAD redirects).
                // Only follow same-host redirects to prevent credential leakage to a different server.
                if (resp.code in 301..308) {
                    val location = resp.header("Location") ?: return@addInterceptor resp
                    val redirectHost = location.toHttpUrlOrNull()?.host
                    if (redirectHost == null || redirectHost != req.url.host) {
                        return@addInterceptor resp
                    }
                    resp.close()
                    val redirectReq = req.newBuilder().url(location).build()
                    chain.proceed(redirectReq)
                } else resp
            }
            .build()
    }

    override suspend fun testConnection(): Result<Unit> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
            client.newCall(req).execute().use { resp ->
                Timber.d("WebDAV testConnection %d url=%s", resp.code, baseUrl)
                if (!resp.isSuccessful && resp.code != 207) {
                    throw Exception("HTTP ${resp.code} ${resp.message}")
                }
            }
        }
    }.onFailure { e -> Timber.e(e, "WebDAV testConnection failed") }

    override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "1").build()
            client.newCall(req).execute().use { resp ->
                if (resp.code != 207) throw Exception("HTTP ${resp.code}")
                parsePropfind(resp.body?.string() ?: "", remotePath)
            }
        }
    }

    override suspend fun uploadFile(
        localStream: InputStream,
        remotePath: String,
        sizeBytes: Long,
        onProgress: (Long) -> Unit,
    ): Result<RemoteFile> = runCatching {
        withContext(Dispatchers.IO) {
            val body = object : RequestBody() {
                override fun contentType() = "application/octet-stream".toMediaType()
                override fun contentLength() = sizeBytes
                override fun writeTo(sink: BufferedSink) {
                    localStream.source().use { source -> sink.writeAll(source) }
                }
            }
            // Upload to a hidden temp sibling first, then MOVE it onto the destination. A
            // failed PUT leaves the real file untouched instead of overwriting it with a
            // truncated body; the MOVE is a server-side atomic-ish swap.
            val tmpPath = tempPathFor(remotePath)
            val req = Request.Builder().url(url(tmpPath)).put(body).build()
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
            }
            moveFile(tmpPath, remotePath).getOrElse { e ->
                runCatching { deleteFile(tmpPath) }
                throw e
            }
            onProgress(sizeBytes)
            getFileMetadata(remotePath).getOrThrow()
        }
    }

    private fun tempPathFor(remotePath: String): String {
        val dir = remotePath.substringBeforeLast('/', "")
        val name = remotePath.substringAfterLast('/')
        val tmp = ".$name.sfpart"
        return if (dir.isEmpty()) tmp else "$dir/$tmp"
    }

    override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(remotePath)).get().build()
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) throw Exception("Download HTTP ${resp.code}")
                val buf = ByteArray(65536)
                var total = 0L
                resp.body?.byteStream()?.use { src ->
                    var n: Int
                    while (src.read(buf).also { n = it } != -1) {
                        destination.write(buf, 0, n)
                        total += n
                        onProgress(total)
                    }
                }
            }
        }
    }

    override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(remotePath)).delete().build()
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) throw Exception("Delete HTTP ${resp.code}")
            }
        }
    }

    override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
            client.newCall(req).execute().use { resp ->
                // 405 = directory already exists (most servers)
                // 423 = Locked — SFTPGo returns this when the dir exists and has a lock;
                //       treat as "already there", not a failure, so uploads inside it proceed.
                if (!resp.isSuccessful && resp.code != 405 && resp.code != 423) {
                    throw Exception("MKCOL HTTP ${resp.code}")
                }
            }
        }
    }

    override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
            client.newCall(req).execute().use { resp ->
                if (resp.code != 207) throw Exception("HTTP ${resp.code}")
                // Depth:0 returns exactly the requested resource as the single response entry.
                // parsePropfind normally drops the first entry (the parent dir) for Depth:1
                // directory listings, so pass dropFirst=false here.
                parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'), dropFirst = false)
                    .firstOrNull() ?: throw Exception("File not found")
            }
        }
    }

    override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
        withContext(Dispatchers.IO) {
            val req = Request.Builder().url(url(fromPath))
                .method("MOVE", null)
                .header("Destination", url(toPath))
                .header("Overwrite", "T")
                .build()
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) throw Exception("MOVE HTTP ${resp.code}")
            }
        }
    }

    // Build a properly percent-encoded URL. addPathSegments encodes each segment (spaces,
    // ampersands, and — critically — non-ASCII like "café"), which keeps OkHttp from rejecting
    // non-ASCII in the WebDAV MOVE "Destination" header and avoids malformed request URLs.
    protected fun url(path: String): String {
        val base = baseUrl.toHttpUrlOrNull() ?: return "$baseUrl/${path.trimStart('/')}"
        return base.newBuilder().addPathSegments(path.trimStart('/')).build().toString()
    }

    private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
        val results = mutableListOf<RemoteFile>()
        val factory = XmlPullParserFactory.newInstance()
        factory.isNamespaceAware = true
        val parser = factory.newPullParser()
        parser.setInput(xml.reader())

        var href = ""; var isCollection = false; var contentLength = 0L
        var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null
        var inResponse = false; var inProp = false

        var eventType = parser.eventType
        while (eventType != XmlPullParser.END_DOCUMENT) {
            val tag = parser.name?.substringAfterLast(':')?.lowercase()
            when (eventType) {
                XmlPullParser.START_TAG -> when (tag) {
                    "response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null }
                    "prop" -> inProp = true
                    "href" -> if (!inProp) href = parser.nextText().trim()
                    "collection" -> if (inProp) isCollection = true
                    "getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L
                    "getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim())
                    "getetag" -> if (inProp) etag = parser.nextText().trim().trim('"')
                    "getcontenttype" -> if (inProp) contentType = parser.nextText().trim()
                }
                XmlPullParser.END_TAG -> when (tag) {
                    "prop" -> inProp = false
                    "response" -> if (inResponse && href.isNotBlank()) {
                        val rawName = href.trimEnd('/').substringAfterLast('/')
                        // Decode %XX escapes but NOT '+' → space. java.net.URLDecoder applies the
                        // form-encoding rule where '+' means space; in a URL *path* a literal '+' is a
                        // real character. That rule turned the file "6103+.pdf" into "6103 .pdf", after
                        // which every GET/DELETE asked for "6103%20.pdf" and 404'd forever — the file
                        // could never sync. decodeWebDavSegment leaves '+' alone.
                        val name = try { decodeWebDavSegment(rawName) } catch (_: Exception) { rawName }
                        // Guard against path-traversal sequences delivered by a malicious server
                        if (name.contains("..") || name.contains('/') || name.contains('\\')) {
                            inResponse = false
                        } else {
                            val relPath = "$parentPath/$name".replace("//", "/")
                            results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
                            inResponse = false
                        }
                    }
                }
            }
            eventType = parser.next()
        }
        return if (dropFirst) results.drop(1) else results
    }

    private fun parseHttpDate(value: String): Instant = try {
        DateTimeFormatter.RFC_1123_DATE_TIME.parse(value, Instant::from)
    } catch (_: Exception) { Instant.EPOCH }

    companion object {
        private val PROPFIND_BODY = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
  <d:prop>
    <d:resourcetype/><d:getcontentlength/><d:getlastmodified/><d:getetag/><d:getcontenttype/>
  </d:prop>
</d:propfind>""".toRequestBody("application/xml".toMediaType())
    }
}

/**
 * Percent-decode a single WebDAV path segment from a PROPFIND <href>, WITHOUT the `+` → space rule
 * that [java.net.URLDecoder] applies (that rule is for application/x-www-form-urlencoded query
 * strings, not URL paths). A literal '+' in a path is a real character: the file "6103+.pdf" must
 * stay "6103+.pdf", not become "6103 .pdf". Only %XX escapes are decoded; the resulting bytes are
 * reassembled and read as UTF-8. Kept top-level and pure so it unit-tests without the Android runtime.
 */
internal fun decodeWebDavSegment(raw: String): String {
    if (raw.indexOf('%') < 0) return raw   // fast path: nothing to decode, '+' passes through untouched
    val out = java.io.ByteArrayOutputStream(raw.length)
    var i = 0
    while (i < raw.length) {
        val c = raw[i]
        if (c == '%' && i + 2 < raw.length) {
            val hi = Character.digit(raw[i + 1], 16)
            val lo = Character.digit(raw[i + 2], 16)
            if (hi >= 0 && lo >= 0) {
                out.write((hi shl 4) + lo)
                i += 3
                continue
            }
        }
        // Literal character (including '+', and any raw non-ASCII) → its own UTF-8 bytes.
        out.write(c.toString().toByteArray(Charsets.UTF_8))
        i++
    }
    return out.toString("UTF-8")
}
