/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.webdav

import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.os.ProxyFileDescriptorCallback
import android.os.storage.StorageManager
import android.system.ErrnoException
import android.system.OsConstants
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.ktor.DavResource
import at.bitfire.dav4jvm.ktor.exception.DavException
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.util.DavUtils
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import com.google.common.cache.LoadingCache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.statement.bodyAsBytes
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import java.io.InterruptedIOException
import java.util.logging.Logger
import javax.annotation.WillClose

@RequiresApi(26)
class RandomAccessCallback @AssistedInject constructor(
    @Assisted @WillClose private val httpClient: HttpClient,
    @Assisted private val url: Url,
    @Assisted private val mimeType: ContentType?,
    @Assisted headResponse: HeadResponse,
    @Assisted private val externalScope: CoroutineScope,
    @ApplicationContext private val context: Context,
    private val logger: Logger
): ProxyFileDescriptorCallback() {

    companion object {

        /**
         * WebDAV resources will be read in chunks of this size (or less at the end of the file).
         */
        const val MAX_PAGE_SIZE = 2 * 1024*1024     // 2 MB

    }

    @AssistedFactory
    interface Factory {
        fun create(httpClient: HttpClient, url: Url, mimeType: ContentType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallback
    }

    data class PageIdentifier(
        val offset: Long,
        val size: Int
    )

    private val dav = DavResource(httpClient, url)

    private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size")
    private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified")

    private val pageLoader = PageLoader(externalScope)
    private val pageCache: LoadingCache<PageIdentifier, ByteArray> = CacheBuilder.newBuilder()
        .maximumSize(10)    // don't cache more than 10 entries (MAX_PAGE_SIZE each)
        .softValues()       // use SoftReference for the page contents so they will be garbage-collected if memory is needed
        .build(pageLoader)  // fetch actual content using pageLoader

    /** This thread will be used for I/O operations like [onRead]. Using the main looper would cause ANRs. */
    private val ioThread = HandlerThread("WebDAV I/O").apply {
        start()
    }

    private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache)


    // file descriptor

    /**
     * Returns a random-access file descriptor that can be used in a DocumentsProvider.
     */
    fun fileDescriptor(): ParcelFileDescriptor {
        val storageManager = context.getSystemService<StorageManager>()!!
        val ioHandler = Handler(ioThread.looper)
        return storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, this, ioHandler)
    }


    // implementation

    override fun onFsync() { /* not used */ }

    override fun onGetSize(): Long = runBlockingFd("onGetFileSize") {
        logger.fine("onGetFileSize $url")
        fileSize
    }

    override fun onRead(offset: Long, size: Int, data: ByteArray) = runBlockingFd("onRead") {
        logger.fine("onRead $url $offset $size")
        pagingReader.read(offset, size, data)
    }

    override fun onWrite(offset: Long, size: Int, data: ByteArray): Int {
        logger.fine("onWrite $url $offset $size")
        // ranged write requests not supported by WebDAV (yet)
        throw ErrnoException("onWrite", OsConstants.EROFS)
    }

    override fun onRelease() {
        logger.fine("onRelease")

        // free resources
        ioThread.quitSafely()
        httpClient.close()
    }


    // scope / cancellation

    /**
     * Runs blocking in [externalScope].
     *
     * Exceptions (including [CancellationException]) are wrapped in an [ErrnoException], as expected by the file
     * descriptor / Storage Access Framework.
     *
     * @param functionName  name of the operation, passed to [ErrnoException] in case of cancellation
     */
    private fun<T> runBlockingFd(functionName: String, block: () -> T): T =
        runBlocking {
            try {
                externalScope.async {
                    block()
                }.await()
            } catch (e: CancellationException) {
                logger.warning("Random file access cancelled in $functionName, throwing ErrnoException(EINTR)")
                throw ErrnoException(functionName, OsConstants.EINTR, e)
            } catch (e: Throwable) {
                throw e.toErrNoException("onRead")
            }
        }

    private fun Throwable.toErrNoException(functionName: String) =
        ErrnoException(
            functionName,
            when (this) {
                is HttpException ->
                    when (statusCode) {
                        403 -> OsConstants.EPERM
                        404 -> OsConstants.ENOENT
                        else -> OsConstants.EIO
                    }
                is IndexOutOfBoundsException -> OsConstants.ENXIO   // no such [device or] address, see man lseek (2)
                is InterruptedIOException -> OsConstants.EINTR
                is PartialContentNotSupportedException -> OsConstants.EOPNOTSUPP
                else -> OsConstants.EIO
            },
            this
        )


    /**
     * Responsible for loading (= downloading) a single page from the WebDAV resource.
     *
     * @param scope     cancellable scope the loader runs in (loader cancels I/O when this scope is cancelled)
     */
    inner class PageLoader(
        private val scope: CoroutineScope
    ): CacheLoader<PageIdentifier, ByteArray>() {

        override fun load(key: PageIdentifier) = runBlocking {
            scope.async {
                loadAsync(key)
            }.await()
        }

        private suspend fun loadAsync(key: PageIdentifier): ByteArray {
            val offset = key.offset
            val size = key.size
            logger.fine("Loading page $url $offset/$size")

            val headers = Headers.build {
                append(HttpHeaders.Accept, DavUtils.acceptAnything(preferred = mimeType))
                documentState.eTag?.let { eTag ->
                    append(HttpHeaders.IfMatch, "\"$eTag\"")
                } ?: documentState.lastModified?.let { lastModified ->
                    append(HttpHeaders.IfUnmodifiedSince, HttpUtils.formatDate(lastModified))
                } ?: throw DavException("ETag/Last-Modified required for random access")
            }

            var result: ByteArray? = null
            dav.getRange(offset, size, headers) { response ->
                if (response.status == HttpStatusCode.OK)       // server doesn't support ranged requests
                    throw PartialContentNotSupportedException()
                if (response.status != HttpStatusCode.PartialContent)
                    throw HttpException.fromResponse(response)

                result = response.bodyAsBytes()
            }
            return result ?: throw DavException("No response body")
        }

    }


    class PartialContentNotSupportedException: Exception()

}
