package jp.juggler.subwaytooter.util

import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteDatabaseCorruptException
import android.database.sqlite.SQLiteOpenHelper
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.RectF
import android.os.Handler
import android.os.SystemClock
import android.provider.BaseColumns
import com.caverock.androidsvg.SVG
import jp.juggler.apng.ApngFrames
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.table.TableCompanion
import jp.juggler.util.LogCategory
import kotlinx.coroutines.channels.Channel
import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.math.ceil

class CustomEmojiCache(
    val context: Context,
    private val handler: Handler,
) {

    companion object {

        private val log = LogCategory("CustomEmojiCache")

        internal const val DEBUG = false
        internal const val CACHE_MAX = 512 // 使用中のビットマップは掃除しないので、頻度によってはこれより多くなることもある
        internal const val ERROR_EXPIRE = 60000L * 10

        private val elapsedTime: Long
            get() = SystemClock.elapsedRealtime()

        // カスタム絵文字のキャッシュ専用のデータベースファイルを作る
        // (DB破損などの際に削除してしまえるようにする)
        private const val CACHE_DB_NAME = "emoji_cache_db"
        private const val CACHE_DB_VERSION = 1
    }

    private class DbCache(
        val id: Long,
        val timeUsed: Long,
        val data: ByteArray,
    ) {

        companion object : TableCompanion {

            const val table = "custom_emoji_cache"

            const val COL_ID = BaseColumns._ID
            const val COL_TIME_SAVE = "time_save"
            const val COL_TIME_USED = "time_used"
            const val COL_URL = "url"
            const val COL_DATA = "data"

            override fun onDBCreate(db: SQLiteDatabase) {
                db.execSQL(
                    """create table if not exists $table
						($COL_ID INTEGER PRIMARY KEY
						,$COL_TIME_SAVE integer not null
						,$COL_TIME_USED integer not null
						,$COL_URL text not null
						,$COL_DATA blob not null
						)""".trimIndent()
                )
                db.execSQL("create unique index if not exists ${table}_url on $table($COL_URL)")
                db.execSQL("create index if not exists ${table}_old on $table($COL_TIME_USED)")
            }

            override fun onDBUpgrade(
                db: SQLiteDatabase,
                oldVersion: Int,
                newVersion: Int,
            ) {
            }

            fun load(db: SQLiteDatabase, url: String, now: Long) =
                db.rawQuery(
                    "select $COL_ID,$COL_TIME_USED,$COL_DATA from $table where $COL_URL=?",
                    arrayOf(url)
                )?.use { cursor ->
                    if (cursor.moveToNext()) {
                        DbCache(
                            id = cursor.getLong(cursor.getColumnIndex(COL_ID)),
                            timeUsed = cursor.getLong(cursor.getColumnIndex(COL_TIME_USED)),
                            data = cursor.getBlob(cursor.getColumnIndex(COL_DATA))
                        ).apply {
                            if (now - timeUsed >= 5 * 3600000L) {
                                db.update(
                                    table,
                                    ContentValues().apply {
                                        put(COL_TIME_USED, now)
                                    },
                                    "$COL_ID=?",
                                    arrayOf(id.toString())
                                )
                            }
                        }
                    } else {
                        null
                    }
                }

            fun sweep(db: SQLiteDatabase, now: Long) {
                val expire = now - TimeUnit.DAYS.toMillis(30)
                db.delete(
                    table,
                    "$COL_TIME_USED < ?",
                    arrayOf(expire.toString())
                )
            }

            fun update(db: SQLiteDatabase, url: String, data: ByteArray) {
                val now = System.currentTimeMillis()
                db.replace(table,
                    null,
                    ContentValues().apply {
                        put(COL_URL, url)
                        put(COL_DATA, data)
                        put(COL_TIME_USED, now)
                        put(COL_TIME_SAVE, now)
                    }
                )
            }
        }
    }

    private class DbOpenHelper(val context: Context) :
        SQLiteOpenHelper(context, CACHE_DB_NAME, null, CACHE_DB_VERSION) {

        private val tables = arrayOf(DbCache)
        override fun onCreate(db: SQLiteDatabase) =
            tables.forEach { it.onDBCreate(db) }

        override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
            tables.forEach { it.onDBUpgrade(db, oldVersion, newVersion) }

        fun deleteDatabase() {
            try {
                close()
            } catch (ex: Throwable) {
                log.trace(ex)
            }
            try {
                SQLiteDatabase.deleteDatabase(context.getDatabasePath(databaseName))
            } catch (ex: Throwable) {
                log.trace(ex)
            }
        }
    }

    private class CacheItem(val url: String, var frames: ApngFrames?) {

        var timeUsed: Long = elapsedTime
    }

    private class Request(
        val refTarget: WeakReference<Any>,
        val url: String,
        val onLoadComplete: () -> Unit,
    )

    // APNGデコード済のキャッシュデータ
    private val cache = ConcurrentHashMap<String, CacheItem>()

    // エラーキャッシュ
    private val cacheError = ConcurrentHashMap<String, Long>()
    private val cacheErrorItem = CacheItem("error", null)

    // リクエストキュー
    // キャンセル操作の都合上、アクセス時に排他が必要
    private val queue = LinkedList<Request>()

    private val dbOpenHelper = DbOpenHelper(context)

    private var lastSweepDbCache = 0L

    private val workerLock = Channel<Unit>(capacity = 8)

    // 他の変数より後に初期化すること
    private val workers =
        (1..4).map { Worker(workerLock) }.toList()

    // DB処理を行い、SQLiteDatabaseCorruptExceptionを検出したらDBを削除してリトライする
    private fun <T : Any> useDbCache(block: (SQLiteDatabase) -> T?): T? {
        for (ignored in (1..3)) {
            try {
                val db = dbOpenHelper.writableDatabase ?: break
                return block(db)
            } catch (ex: SQLiteDatabaseCorruptException) {
                log.trace(ex)
                dbOpenHelper.deleteDatabase()
            } catch (ex: Throwable) {
                log.trace(ex)
                break
            }
        }
        return null
    }

    // ネットワーク接続が切り替わったタイミングでエラーキャッシュをクリアする
    fun onNetworkChanged() {
        cacheError.clear()
    }

    // カラムのリロードボタンを押したタイミングでエラーキャッシュをクリアする
    fun clearErrorCache() {
        cacheError.clear()
    }

    // tag_target を持つリクエストまたはtagがGCされたリクエストをキューから除去する
    fun cancelRequest(refTarget: WeakReference<Any>) {

        val targetTag = refTarget.get() ?: return

        synchronized(queue) {
            val it = queue.iterator()
            while (it.hasNext()) {
                val request = it.next()
                val tag = request.refTarget.get()
                if (tag === null || tag === targetTag) it.remove()
            }
        }
    }

    private fun getCached(now: Long, url: String): CacheItem? {

        // 成功キャッシュ
        val item = cache[url]
        if (item != null) {
            item.timeUsed = now
            return item
        }

        // エラーキャッシュ
        val timeError = cacheError[url]
        if (timeError != null && now < timeError + ERROR_EXPIRE) {
            return cacheErrorItem
        }

        return null
    }

    fun getFrames(
        refDrawTarget: WeakReference<Any>?,
        url: String,
        onLoadComplete: () -> Unit,
    ): ApngFrames? {
        try {
            if (refDrawTarget?.get() == null) {
                log.e("draw: DrawTarget is null ")
                return null
            }

            cancelRequest(refDrawTarget)

            // APNG frame cache
            synchronized(cache) {
                getCached(elapsedTime, url)?.let { return it.frames }
            }

            // キャンセル操作の都合上、排他が必要
            synchronized(queue) {
                queue.addLast(Request(refDrawTarget, url, onLoadComplete))
            }

            workers.first().notifyEx()
        } catch (ex: Throwable) {
            log.trace(ex)
            // たまにcache変数がなぜかnullになる端末があるらしい
        }
        return null
    }

    fun delete() {
        synchronized(cache) {
            for (entry in cache.entries) {
                entry.value.frames?.dispose()
            }
            cache.clear()
            cacheError.clear()
        }
        dbOpenHelper.deleteDatabase()
    }

    private inner class Worker(waiter: Channel<Unit>) : WorkerBase(waiter) {

        override fun cancel() {
            // このスレッドはプロセスが生きてる限りキャンセルされない
        }

        override suspend fun run() {
            var ts: Long
            var te: Long
            while (true) {
                try {
                    var queueSize: Int
                    val request = synchronized(queue) {
                        val x = if (queue.isNotEmpty()) queue.removeFirst() else null
                        queueSize = queue.size
                        x
                    }

                    if (request == null) {
                        if (DEBUG) log.d("wait")

                        synchronized(cache) {
                            val now = System.currentTimeMillis()
                            if (now - lastSweepDbCache >= TimeUnit.DAYS.toMillis(1)) {
                                lastSweepDbCache = now
                                useDbCache { DbCache.sweep(it, now) }
                            }
                        }

                        ts = elapsedTime
                        waitEx(86400000L)
                        te = elapsedTime
                        if (te - ts >= 200L) log.d("sleep ${te - ts}ms")
                        continue
                    }

                    // 描画先がGCされたなら何もしない
                    request.refTarget.get() ?: continue

                    ts = elapsedTime
                    var cacheSize: Int = -1
                    val cacheUsed = synchronized(cache) {
                        val now = elapsedTime
                        val item = getCached(now, request.url)
                        if (item != null) {
                            if (item.frames != null) {
                                fireCallback(request)
                            }
                            return@synchronized true
                        }
                        sweepCache(now)
                        cacheSize = cache.size
                        return@synchronized false
                    }
                    te = elapsedTime
                    if (te - ts >= 200L) log.d("cache_used? ${te - ts}ms")

                    if (cacheUsed) continue

                    if (DEBUG) log.d("start get image. queue_size=$queueSize, cache_size=$cacheSize url=${request.url}")

                    val now = System.currentTimeMillis()

                    // データベースからロードしてみる
                    ts = elapsedTime
                    val dbCache = useDbCache { DbCache.load(it, request.url, now) }
                    te = elapsedTime
                    if (te - ts >= 200L) log.d("DbCache.load ${te - ts}ms")

                    var data = dbCache?.data

                    // データベースにblobがなければHTTPリクエスト
                    if (data == null) {
                        ts = elapsedTime
                        data = try {
                            App1.getHttpCached(request.url)
                        } catch (ex: Throwable) {
                            log.trace(ex, "get failed. url=${request.url}")
                            null
                        }
                        te = elapsedTime
                        if (te - ts >= 200L) log.d("image get? ${te - ts}ms")

                        if (data != null) {
                            useDbCache { db ->
                                DbCache.update(db, request.url, data)
                            }
                        }
                    }

                    ts = elapsedTime
                    val frames = try {
                        data?.let { decodeAPNG(it, request.url) }
                    } catch (ex: Throwable) {
                        log.e(ex, "decode failed.")
                        null
                    }
                    te = elapsedTime
                    if (te - ts >= 200L) log.d("image decode? ${te - ts}ms")

                    ts = elapsedTime
                    synchronized(cache) {
                        if (frames == null) {
                            cacheError[request.url] = elapsedTime
                        } else {
                            var item: CacheItem? = cache[request.url]
                            if (item == null) {
                                // 新しいキャッシュ項目
                                item = CacheItem(request.url, frames)
                                cache[request.url] = item
                            } else {
                                // 古いキャッシュを更新する
                                item.frames?.dispose()
                                item.frames = frames
                            }
                            fireCallback(request)
                        }
                    }
                    te = elapsedTime
                    if (te - ts >= 200L) log.d("update_cache ${te - ts}ms")
                } catch (ex: Throwable) {
                    log.trace(ex)

                    // Fujitsu F-01H（F01H）, 2048MB RAM, Android 6.0
                    // java.lang.NullPointerException:
                    // at java.util.concurrent.ConcurrentHashMap.get (ConcurrentHashMap.java:772)
                    // at jp.juggler.subwaytooter.util.CustomEmojiCache$Worker.run (CustomEmojiCache.java:183)

                    waitEx(3000L)
                }
            }
        }

        private fun fireCallback(request: Request) {
            handler.post { request.onLoadComplete() }
        }

        private fun sweepCache(now: Long) {

            // キャッシュ限界を超過した数
            val over = cache.size - CACHE_MAX

            // 超過した数がある程度大きくなるまで掃除しない
            if (over <= 64) return

            // 掃除する候補
            val list = ArrayList<CacheItem>()
            for (item in cache.values) {
                // 最近使われていないものが掃除対象
                if (now - item.timeUsed > 1000L) list.add(item)
            }

            // 昇順ソート
            list.sortBy { it.timeUsed }

            // 古い物から順に捨てる
            var removed = 0
            for (item in list) {
                cache.remove(item.url)
                item.frames?.dispose()
                if (++removed >= over) break
            }
        }

        private fun decodeAPNG(data: ByteArray, url: String): ApngFrames? {
            try {
                // APNGをデコード
                val x = ApngFrames.parse(64) { ByteArrayInputStream(data) }
                if (x != null) return x
                // fall thru
            } catch (ex: Throwable) {
                if (DEBUG) log.trace(ex)
                log.e(ex, "PNG decode failed. $url ")
            }

            // 通常のビットマップでのロードを試みる
            try {
                val b = decodeBitmap(data, 128)
                if (b != null) {
                    if (DEBUG) log.d("bitmap decoded.")
                    return ApngFrames(b)
                } else {
                    log.e("Bitmap decode returns null. $url")
                }

                // fall thru
            } catch (ex: Throwable) {
                log.e(ex, "Bitmap decode failed. $url")
            }

            // SVGのロードを試みる
            try {
                val b = decodeSVG(url, data, 128.toFloat())
                if (b != null) {
                    if (DEBUG) log.d("SVG decoded.")
                    return ApngFrames(b)
                }

                // fall thru
            } catch (ex: Throwable) {
                log.e(ex, "SVG decode failed. $url")
            }

            return null
        }

        private val options = BitmapFactory.Options()

        private fun decodeBitmap(
            data: ByteArray,
            @Suppress("SameParameterValue") pixelMax: Int,
        ): Bitmap? {
            options.inJustDecodeBounds = true
            options.inScaled = false
            options.outWidth = 0
            options.outHeight = 0
            BitmapFactory.decodeByteArray(data, 0, data.size, options)
            var w = options.outWidth
            var h = options.outHeight
            if (w <= 0 || h <= 0) {
                log.e("can't decode bounds.")
                return null
            }
            var bits = 0
            while (w > pixelMax || h > pixelMax) {
                ++bits
                w = w shr 1
                h = h shr 1
            }
            options.inJustDecodeBounds = false
            options.inSampleSize = 1 shl bits
            return BitmapFactory.decodeByteArray(data, 0, data.size, options)
        }

        private fun decodeSVG(
            url: String,
            data: ByteArray,
            @Suppress("SameParameterValue") pixelMax: Float,
        ): Bitmap? {
            try {
                val svg = SVG.getFromInputStream(ByteArrayInputStream(data))

                // the width in pixels, or -1 if there is no width available.
                // the height in pixels, or -1 if there is no height available.
                val srcW = svg.documentWidth
                val srcH = svg.documentHeight
                val aspect = if (srcW <= 0f || srcH <= 0f) {
                    // widthやheightの情報がない
                    1f
                } else {
                    srcW / srcH
                }

                val dstW: Float
                val dstH: Float
                if (aspect >= 1f) {
                    dstW = pixelMax
                    dstH = pixelMax / aspect
                } else {
                    dstH = pixelMax
                    dstW = pixelMax * aspect
                }
                val wCeil = ceil(dstW)
                val hCeil = ceil(dstH)

                // Create a Bitmap to render our SVG to
                val b = Bitmap.createBitmap(wCeil.toInt(), hCeil.toInt(), Bitmap.Config.ARGB_8888)
                // Create a Canvas to use for rendering
                val canvas = Canvas(b)

                svg.renderToCanvas(
                    canvas,
                    if (aspect >= 1f) {
                        RectF(0f, hCeil - dstH, dstW, dstH) // 後半はw,hを指定する
                    } else {
                        RectF(wCeil - dstW, 0f, dstW, dstH) // 後半はw,hを指定する
                    }
                )
                return b
            } catch (ex: Throwable) {
                log.e(ex, "decodeSVG failed. $url")
            }
            return null
        }
    }
}
