Pith - varnish
package com.vgmlr.varnish

import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Rect
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.provider.Settings
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import java.io.File
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext

data class MediaFile(
    val path: String,
    val name: String,
    val folder: String,
    val size: Long,
    val modified: Long,
    val video: Boolean
)

data class MediaFolder(
    val path: String,
    val name: String,
    val count: Int,
    val covers: List<MediaFile>,
    val locked: Boolean
) {
    val cover: MediaFile? get() = covers.firstOrNull()
}

data class ScanResult(
    val folders: List<MediaFolder> = emptyList(),
    val files: Map<String, List<MediaFile>> = emptyMap()
)

data class MediaInfo(
    val name: String,
    val path: String,
    val size: String,
    val resolution: String,
    val modified: String
)

data class AppChoice(val label: String, val pkg: String)

class VarnishRepo(context: Context) {
    private val app = context.applicationContext
    private val dao = VarnishDb.get(app).marks()

    @Volatile
    private var rootPaths: List<String> = emptyList()

    // access

    fun hasAccess(): Boolean = Environment.isExternalStorageManager()

    fun accessIntent(): Intent = Intent(
        Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
        "package:${app.packageName}".toUri()
    )

    // scan

    @Suppress("DEPRECATION")
    fun roots(): List<String> {
        val primary = Environment.getExternalStorageDirectory().absolutePath
        val extra = app.getExternalFilesDirs(null).drop(1).mapNotNull {
            it?.absolutePath?.substringBefore("/Android/", "")?.takeIf(String::isNotEmpty)
        }
        return (listOf(primary) + extra).distinct()
            .filter { File(it).canRead() }
            .also { rootPaths = it }
    }

    suspend fun scan(s: VarnishSettings): ScanResult = withContext(Dispatchers.IO) {
        val found = HashMap<String, List<MediaFile>>()
        for (root in roots().map(::File)) {
            if (s.scan == ScanMode.FOLDERS) {
                root.listFiles()?.forEach { d ->
                    ensureActive()
                    if (d.isDirectory && enterable(d, s)) {
                        mediaIn(d, s)?.takeIf { it.isNotEmpty() }?.let { found[d.absolutePath] = it }
                    }
                }
            } else {
                val stack = ArrayDeque<File>().apply { add(root) }
                while (stack.isNotEmpty()) {
                    ensureActive()
                    val dir = stack.removeLast()
                    val kids = dir.listFiles() ?: continue
                    if (!s.showHidden && kids.any { it.name == NOMEDIA }) continue
                    val media = ArrayList<MediaFile>()
                    for (k in kids) {
                        if (k.isDirectory) {
                            if (enterable(k, s)) stack.add(k)
                        } else {
                            toMedia(k, s)?.let(media::add)
                        }
                    }
                    if (media.isNotEmpty()) found[dir.absolutePath] = media
                }
            }
        }
        dao.pathsNow(MarkKind.PINNED).forEach { p ->
            val d = File(p)
            if (p !in found && d.isDirectory && enterable(d, s)) {
                found[p] = mediaIn(d, s) ?: emptyList()
            }
        }
        val folders = found.map { (p, list) -> folderOf(File(p), list) }
            .sortedWith(compareBy(NaturalOrder) { f: MediaFolder -> f.name }.thenBy { it.path })
        ScanResult(folders, found)
    }

    suspend fun folderFiles(path: String, s: VarnishSettings): List<MediaFile> =
        withContext(Dispatchers.IO) {
            val list = File(path).listFiles()
                ?.mapNotNull { if (it.isFile) toMedia(it, s) else null }
                .orEmpty()
            sortFiles(list, s.sort)
        }

    suspend fun mediaAt(paths: Collection<String>, s: VarnishSettings): List<MediaFile> =
        withContext(Dispatchers.IO) {
            val list = paths.mapNotNull { p -> File(p).takeIf { it.isFile }?.let { toMedia(it, s) } }
            sortFiles(list, s.sort)
        }

    fun search(result: ScanResult, q: String, sort: SortMode): List<MediaFile> {
        val t = q.trim()
        if (t.isEmpty()) return emptyList()
        val date = VarnishClock.matcher(t)
        val hits = result.files.values.flatten().filter {
            it.name.contains(t, true) ||
                    File(it.folder).name.contains(t, true) ||
                    date?.invoke(it.modified) == true
        }
        return sortFiles(hits, sort)
    }

    fun sortFiles(list: List<MediaFile>, sort: SortMode): List<MediaFile> {
        val name = compareBy(NaturalOrder) { f: MediaFile -> f.name }
        val by = when (sort) {
            SortMode.AZ -> name
            SortMode.ZA -> name.reversed()
            SortMode.NEW -> compareByDescending<MediaFile> { it.modified }.then(name)
            SortMode.OLD -> compareBy<MediaFile> { it.modified }.then(name)
        }
        return list.sortedWith(by)
    }

    fun isLocked(dir: File): Boolean {
        val roots = rootPaths.ifEmpty { roots() }
        val p = dir.absolutePath
        if (p in roots) return true
        val parent = dir.parent
        if (parent != null && parent in roots &&
            VarnishConfig.SYSTEM_DIRS.any { it.equals(dir.name, true) }
        ) {
            return true
        }
        if (roots.any { p.startsWith("$it/Android/") }) return true
        return !dir.canWrite()
    }

    fun virtualFolder(path: String, name: String, list: List<MediaFile>): MediaFolder =
        MediaFolder(path, name, list.size, coversOf(list), locked = true)

    private fun folderOf(dir: File, list: List<MediaFile>): MediaFolder =
        MediaFolder(dir.absolutePath, dir.name, list.size, coversOf(list), isLocked(dir))

    private fun coversOf(list: List<MediaFile>): List<MediaFile> {
        val (vids, imgs) = list.partition { it.video }
        return (
                imgs.sortedByDescending { it.modified } + vids.sortedByDescending { it.modified }
                ).take(COVERS)
    }

    private fun mediaIn(dir: File, s: VarnishSettings): List<MediaFile>? {
        val kids = dir.listFiles() ?: return null
        if (!s.showHidden && kids.any { it.name == NOMEDIA }) return null
        return kids.mapNotNull { if (it.isFile) toMedia(it, s) else null }
    }

    private fun enterable(dir: File, s: VarnishSettings): Boolean {
        if (dir.parentFile?.name == "Android" && dir.name in SEALED) return false
        return s.showHidden || !dir.name.startsWith(".")
    }

    private fun toMedia(f: File, s: VarnishSettings): MediaFile? {
        if (!s.showHidden && f.name.startsWith(".")) return null
        val video = videoFlag(f.name, s.types) ?: return null
        return MediaFile(f.absolutePath, f.name, f.parent.orEmpty(), f.length(), f.lastModified(), video)
    }

    private fun videoFlag(name: String, types: FileTypes): Boolean? {
        val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
        return when {
            ext in VarnishConfig.IMAGE_EXT && types != FileTypes.VIDEOS -> false
            ext in VarnishConfig.VIDEO_EXT && types != FileTypes.IMAGES -> true
            else -> null
        }
    }

    // marks

    fun marks(kind: Int): Flow<Set<String>> = dao.paths(kind).map { it.toHashSet() }

    suspend fun setMarks(kind: Int, paths: Collection<String>, on: Boolean) =
        withContext(Dispatchers.IO) {
            paths.chunked(500).forEach { c ->
                if (on) dao.add(c.map { Mark(it, kind) }) else dao.remove(kind, c)
            }
        }

    // file ops

    suspend fun deleteFiles(paths: List<String>): List<String> =
        withContext(Dispatchers.IO) { deleteNow(paths) }

    suspend fun deleteFolder(path: String): Boolean =
        withContext(Dispatchers.IO) {
            val dir = File(path)
            if (isLocked(dir)) return@withContext false
            val inner = dir.walkBottomUp().filter { it.isFile }.map { it.absolutePath }.toList()
            val ok = dir.deleteRecursively()
            dao.purge(path, treeLike(path))
            inner.forEach { VarnishThumbs.drop(app, it) }
            rescan(inner)
            ok
        }

    suspend fun renameFile(path: String, base: String): String? = withContext(Dispatchers.IO) {
        val src = File(path)
        val name = base.trim()
        if (!validName(name)) return@withContext null
        val dst = File(src.parentFile, if (src.extension.isEmpty()) name else "$name.${src.extension}")
        if (dst.absolutePath == path) return@withContext path
        if (dst.exists() || !src.renameTo(dst)) return@withContext null
        dao.move(path, dst.absolutePath)
        VarnishThumbs.move(app, path, dst.absolutePath)
        rescan(listOf(path, dst.absolutePath))
        dst.absolutePath
    }

    suspend fun renameFolder(path: String, newName: String): String? = withContext(Dispatchers.IO) {
        val src = File(path)
        val name = newName.trim()
        if (isLocked(src) || !validName(name)) return@withContext null
        val dst = File(src.parentFile, name)
        if (dst.absolutePath == path) return@withContext path
        if (dst.exists()) return@withContext null
        val old = src.walk().filter { it.isFile }.map { it.absolutePath }.toList()
        if (!src.renameTo(dst)) return@withContext null
        val to = dst.absolutePath
        dao.move(path, to)
        dao.moveTree(to, path.length + 1, treeLike(path))
        old.forEach { VarnishThumbs.move(app, it, to + it.substring(path.length)) }
        rescan(old + old.map { to + it.substring(path.length) })
        to
    }

    suspend fun createFolder(parent: String, name: String): String? = withContext(Dispatchers.IO) {
        val n = name.trim()
        if (!validName(n)) return@withContext null
        val dir = freeFile(File(parent), n, "")
        if (!dir.mkdirs()) return@withContext null
        dao.add(listOf(Mark(dir.absolutePath, MarkKind.PINNED)))
        dir.absolutePath
    }

    suspend fun moveFile(path: String, destDir: String): String? = withContext(Dispatchers.IO) {
        val src = File(path)
        val dir = File(destDir)
        if (!dir.isDirectory || dir.absolutePath == src.parent) return@withContext null
        val dst = freeFile(dir, src.nameWithoutExtension, src.extension)
        val moved = src.renameTo(dst) || runCatching {
            src.copyTo(dst)
            dst.setLastModified(src.lastModified())
            src.delete()
            true
        }.getOrDefault(false)
        if (!moved) return@withContext null
        dao.move(path, dst.absolutePath)
        VarnishThumbs.move(app, path, dst.absolutePath)
        rescan(listOf(path, dst.absolutePath))
        dst.absolutePath
    }

    suspend fun cropSave(path: String, rect: Rect, s: VarnishSettings): String? =
        withContext(Dispatchers.IO) {
            VarnishCodec.crop(File(path), rect, s.outFormat, s.quality, s.keepExif)
                ?.absolutePath
                ?.also { rescan(listOf(it)) }
        }

    suspend fun info(path: String): MediaInfo = withContext(Dispatchers.IO) {
        val f = File(path)
        val dim = VarnishCodec.bounds(f)
        MediaInfo(
            name = f.name,
            path = f.parent.orEmpty(),
            size = sizeLabel(f.length()),
            resolution = dim?.let { "${it.first} × ${it.second}" } ?: "-",
            modified = VarnishClock.stamp(f.lastModified())
        )
    }

    fun treePath(uri: Uri): String? {
        val id = runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull() ?: return null
        val dir = idFile(id)
        return dir.absolutePath.takeIf { dir.isDirectory }
    }

    suspend fun resolve(uri: Uri): String? = withContext(Dispatchers.IO) {
        val p = when (uri.scheme) {
            "file" -> uri.path?.takeIf { it.startsWith("/") }
            "content" -> docPath(uri) ?: dataPath(uri)
            else -> null
        }
        p?.takeIf { !hasAccess() || File(it).canRead() } ?: copyIn(uri)
    }

    private fun idFile(id: String): File {
        val vol = id.substringBefore(':')
        val rel = id.substringAfter(':', "")
        @Suppress("DEPRECATION")
        val base = if (vol.equals("primary", true)) {
            Environment.getExternalStorageDirectory().absolutePath
        } else "/storage/$vol"
        return if (rel.isEmpty()) File(base) else File(base, rel)
    }

    private fun docPath(uri: Uri): String? {
        if (uri.authority != EXT_DOCS || !DocumentsContract.isDocumentUri(app, uri)) return null
        val id = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull() ?: return null
        return idFile(id).absolutePath
    }

    @Suppress("DEPRECATION")
    private fun dataPath(uri: Uri): String? {
        val src = if (DocumentsContract.isDocumentUri(app, uri)) {
            runCatching { MediaStore.getMediaUri(app, uri) }.getOrNull() ?: uri
        } else uri
        return runCatching {
            app.contentResolver.query(src, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)
                ?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
        }.getOrNull()?.takeIf { it.startsWith("/") }
    }

    private fun copyIn(uri: Uri): String? = runCatching {
        val cr = app.contentResolver
        val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(cr.getType(uri)) ?: "jpg"
        val raw = cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
            ?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
        val name = raw?.substringAfterLast('/')?.takeIf(::validName) ?: "image"
        val known = name.substringAfterLast('.', "").lowercase(Locale.ROOT) in VarnishConfig.IMAGE_EXT
        val dir = File(app.cacheDir, INBOX).apply { deleteRecursively(); mkdirs() }
        val dst = File(dir, if (known) name else "$name.$ext")
        val input = cr.openInputStream(uri) ?: return null
        input.use { i -> dst.outputStream().use { i.copyTo(it) } }
        dst.absolutePath
    }.getOrNull()

    private suspend fun deleteNow(paths: List<String>): List<String> {
        val gone = paths.filter { File(it).let { f -> !f.exists() || f.delete() } }
        gone.forEach {
            dao.purge(it, treeLike(it))
            VarnishThumbs.drop(app, it)
        }
        rescan(gone)
        return gone
    }

    private fun validName(n: String) = n.isNotEmpty() && '/' !in n && n != "." && n != ".."

    private fun rescan(paths: List<String>) {
        if (paths.isNotEmpty()) {
            MediaScannerConnection.scanFile(app, paths.toTypedArray(), null, null)
        }
    }

    // intents

    fun shareIntent(paths: List<String>): Intent? {
        val uris = paths.mapNotNull(::uriOf)
        if (uris.isEmpty()) return null
        val mimes = paths.map(::mimeOf).distinct()
        val mime = when {
            mimes.size == 1 -> mimes[0]
            mimes.all { it.startsWith("image/") } -> "image/*"
            mimes.all { it.startsWith("video/") } -> "video/*"
            else -> "*/*"
        }
        val send = if (uris.size == 1) {
            Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
        } else {
            Intent(Intent.ACTION_SEND_MULTIPLE)
                .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
        }
        send.type = mime
        send.clipData = ClipData.newRawUri(null, uris[0]).apply {
            uris.drop(1).forEach { addItem(ClipData.Item(it)) }
        }
        send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        return Intent.createChooser(send, null)
    }

    fun editIntent(path: String, pkg: String): Intent? =
        handoff(Intent.ACTION_EDIT, path, pkg, write = true)

    fun playIntent(path: String, pkg: String): Intent? =
        handoff(Intent.ACTION_VIEW, path, pkg, write = false)

    fun apps(action: String, mime: String): List<AppChoice> {
        val probe = Intent(action).setDataAndType("content://$PROBE/x".toUri(), mime)
        return resolve(probe).mapNotNull { ri ->
            val pkg = ri.activityInfo?.packageName ?: return@mapNotNull null
            if (pkg == app.packageName) null
            else AppChoice(ri.loadLabel(app.packageManager).toString(), pkg)
        }.distinctBy { it.pkg }.sortedWith(compareBy(NaturalOrder) { it.label })
    }

    private fun handoff(action: String, path: String, pkg: String, write: Boolean): Intent? {
        val uri = uriOf(path) ?: return null
        var flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
        if (write) flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
        val i = Intent(action).setDataAndType(uri, mimeOf(path)).addFlags(flags)
        if (pkg.isNotEmpty()) i.setPackage(pkg)
        if (resolve(i).isEmpty()) return null
        return if (pkg.isEmpty()) Intent.createChooser(i, null) else i
    }

    @Suppress("DEPRECATION")
    private fun resolve(i: Intent): List<ResolveInfo> =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            app.packageManager.queryIntentActivities(i, PackageManager.ResolveInfoFlags.of(0))
        } else {
            app.packageManager.queryIntentActivities(i, 0)
        }

    private fun uriOf(path: String): Uri? = runCatching {
        FileProvider.getUriForFile(app, "${app.packageName}.files", File(path))
    }.getOrNull()

    fun mimeOf(path: String): String {
        val ext = path.substringAfterLast('.', "").lowercase(Locale.ROOT)
        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
            ?: if (ext in VarnishConfig.VIDEO_EXT) "video/*" else "image/*"
    }

    private companion object {
        const val NOMEDIA = ".nomedia"
        const val PROBE = "com.vgmlr.varnish.probe"
        const val EXT_DOCS = "com.android.externalstorage.documents"
        const val INBOX = "open"
        const val COVERS = 3
        val SEALED = setOf("data", "obb")
    }
}

fun sizeLabel(bytes: Long): String {
    if (bytes < 1024) return "$bytes B"
    val units = arrayOf("KB", "MB", "GB", "TB")
    var v = bytes / 1024.0
    var i = 0
    while (v >= 1024 && i < units.lastIndex) {
        v /= 1024
        i++
    }
    return String.format(Locale.US, "%.1f %s", v, units[i])
}