/*
 * FileUtil.kt
 * Copyright (C) 2009-2021 Ultrasonic developers
 *
 * Distributed under terms of the GNU GPLv3 license.
 */

package org.moire.ultrasonic.util

import android.content.Context
import android.os.Build
import android.os.Environment
import android.text.TextUtils
import android.util.Pair
import java.io.BufferedWriter
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.OutputStream
import java.io.Serializable
import java.util.Locale
import java.util.SortedSet
import java.util.TreeSet
import java.util.regex.Pattern
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber

/**
 * Provides Ultrasonic specific functions for managing the library files.
 * Base storage functions like rename, create, delete should be handled in Storage.kt
 */
@Suppress("TooManyFunctions")
object FileUtil {

    private val FILE_SYSTEM_UNSAFE = arrayOf("/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|")
    private val FILE_SYSTEM_UNSAFE_DIR = arrayOf("\\", "..", ":", "\"", "?", "*", "<", ">", "|")
    private val MUSIC_FILE_EXTENSIONS =
        listOf("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma", "opus")
    private val VIDEO_FILE_EXTENSIONS =
        listOf("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv")
    private val PLAYLIST_FILE_EXTENSIONS = listOf("m3u")
    private val TITLE_WITH_TRACK = Pattern.compile("^\\d\\d-.*")
    const val SUFFIX_LARGE = ".jpeg"
    const val SUFFIX_SMALL = ".jpeg-small"
    private const val UNNAMED = "unnamed"

    fun getSongFile(track: Track): String {
        val dir = getAlbumDirectory(track)

        // Do not generate new name for offline files. Offline files will have their Path as their Id.
        if (!TextUtils.isEmpty(track.id)) {
            if (track.id.startsWith(dir)) return track.id
        }

        // Generate a file name for the song
        val fileName = StringBuilder(256)
        val trackNumber = track.track

        // check if filename already had track number
        if (track.title != null && !TITLE_WITH_TRACK.matcher(track.title!!).matches()) {
            if (trackNumber != null) {
                if (trackNumber < 10) {
                    fileName.append('0')
                }
                fileName.append(trackNumber).append('-')
            }
        }
        fileName.append(fileSystemSafe(track.title)).append('.')
        if (!TextUtils.isEmpty(track.transcodedSuffix)) {
            fileName.append(track.transcodedSuffix)
        } else {
            fileName.append(track.suffix)
        }
        return "$dir/$fileName"
    }

    fun Track.getPinnedFile(): String {
        return getSongFile(this)
    }

    fun Track.getPartialFile(): String {
        return getParentPath(this.getPinnedFile()) + "/" +
            getPartialFile(getNameFromPath(this.getPinnedFile()))
    }

    fun Track.getCompleteFile(): String {
        return getParentPath(this.getPinnedFile()) + "/" +
            getCompleteFile(getNameFromPath(this.getPinnedFile()))
    }

    @JvmStatic
    fun getPlaylistFile(server: String?, name: String?): File {
        val playlistDir = getPlaylistDirectory(server)
        return File(playlistDir, String.format(Locale.ROOT, "%s.m3u", fileSystemSafe(name)))
    }

    @JvmStatic
    val playlistDirectory: File
        get() {
            val playlistDir = File(ultrasonicDirectory, "playlists")
            ensureDirectoryExistsAndIsReadWritable(playlistDir)
            return playlistDir
        }

    /**
     * Get the directory where we store local copies of the playlists.
     * It is always inside Ultrasonic base directory.
     */
    @JvmStatic
    fun getPlaylistDirectory(server: String? = null): File {
        val playlistDir: File = if (server != null) {
            File(playlistDirectory, server)
        } else {
            playlistDirectory
        }
        ensureDirectoryExistsAndIsReadWritable(playlistDir)
        return playlistDir
    }

    /**
     * Get the album art file for a given album entry
     * @param entry The album entry
     * @return File object. Not guaranteed that it exists
     */
    fun getAlbumArtFile(entry: MusicDirectory.Child): String {
        val albumDir = getAlbumDirectory(entry)
        return getAlbumArtFileForAlbumDir(albumDir)
    }

    /**
     * Get the cache key for a given album entry
     * @param entry The album entry
     * @param large Whether to get the key for the large or the default image
     * @return String The hash key
     */
    fun getAlbumArtKey(entry: MusicDirectory.Child?, large: Boolean): String? {
        if (entry == null) return null
        val albumDir = getAlbumDirectory(entry)
        return getAlbumArtKey(albumDir, large)
    }

    /**
     * Get the cache key for a given artist
     * @param name The artist name
     * @param large Whether to get the key for the large or the default image
     * @return String The hash key
     */
    fun getArtistArtKey(name: String?, large: Boolean): String {
        val artist = fileSystemSafe(name)
        val dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, UNNAMED)
        return getAlbumArtKey(dir, large)
    }

    /**
     * Get the cache key for a given album entry
     * @param albumDirPath The album directory
     * @param large Whether to get the key for the large or the default image
     * @return String The hash key
     */
    private fun getAlbumArtKey(albumDirPath: String, large: Boolean): String {
        val suffix = if (large) SUFFIX_LARGE else SUFFIX_SMALL
        return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDirPath), suffix)
    }

    fun getAvatarFile(username: String?): File? {
        if (username == null) {
            return null
        }
        val albumArtDir = albumArtDirectory
        val md5Hex = Util.md5Hex(username)
        return File(albumArtDir, String.format(Locale.ROOT, "%s%s", md5Hex, SUFFIX_LARGE))
    }

    /**
     * Get the album art file for a given album directory
     * @param albumDir The album directory
     * @return File object. Not guaranteed that it exists
     */
    @JvmStatic
    fun getAlbumArtFileForAlbumDir(albumDir: String): String {
        val key = getAlbumArtKey(albumDir, true)
        return getAlbumArtFile(key)
    }

    /**
     * Get the album art file for a given cache key
     * @param cacheKey The key (== the filename)
     * @return File object. Not guaranteed that it exists
     */
    @JvmStatic
    fun getAlbumArtFile(cacheKey: String): String {
        val albumArtDir = albumArtDirectory.absolutePath
        return "$albumArtDir/$cacheKey"
    }

    /**
     * Get the album art directory quickly, without checking that it exists.
     */
    val albumArtDirectory: File
        get() = File(ultrasonicDirectory, "artwork")

    fun ensureAlbumArtDirectory() {
        val albumArtDir = albumArtDirectory
        ensureDirectoryExistsAndIsReadWritable(albumArtDir)
        ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
    }

    private fun getAlbumDirectory(entry: MusicDirectory.Child): String {
        val dir: String
        val isFileInRoot = !entry.isDirectory && (getParentPath(entry.path) == null)
        if (!TextUtils.isEmpty(entry.path) && !isFileInRoot) {
            val f = fileSystemSafeDir(entry.path)
            dir = String.format(
                Locale.ROOT,
                "%s/%s",
                musicDirectory.path,
                if (entry.isDirectory) f else getParentPath(f) ?: ""
            )
        } else {
            val artist = fileSystemSafe(entry.artist)
            var album = fileSystemSafe(entry.album)
            if (UNNAMED == album) {
                album = fileSystemSafe(entry.title)
            }
            dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, album)
        }
        return dir
    }

    fun createDirectoryForParent(path: String) {
        val dir = getParentPath(path) ?: return
        Storage.createDirsOnPath(dir)
    }

    @Suppress("SameParameterValue")
    private fun getOrCreateDirectory(name: String): File {
        val dir = File(ultrasonicDirectory, name)
        if (!dir.exists() && !dir.mkdirs()) {
            Timber.e("Failed to create %s", name)
        }
        return dir
    }

    var cachedUltrasonicDirectory: File? = null

    // After Android M, the location of the files must be queried differently.
    // GetExternalFilesDir will always return a directory which Ultrasonic
    // can access without any extra privileges.
    @JvmStatic
    val ultrasonicDirectory: File
        get() {
            // Return cached if possible
            if (cachedUltrasonicDirectory != null) return cachedUltrasonicDirectory!!

            @Suppress("DEPRECATION")
            cachedUltrasonicDirectory = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
                File(
                    Environment.getExternalStorageDirectory(),
                    "Android/data/org.moire.ultrasonic"
                )
            } else {
                UApp.applicationContext().getExternalFilesDir(null)!!
            }

            return cachedUltrasonicDirectory!!
        }

    @JvmStatic
    val defaultMusicDirectory: File
        get() = getOrCreateDirectory("music")

    @JvmStatic
    val musicDirectory: AbstractFile
        get() = Storage.mediaRoot.value

    @JvmStatic
    @Suppress("ReturnCount")
    fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Pair<Boolean, Boolean> {
        val noAccess = Pair(false, false)

        if (dir == null) {
            return noAccess
        }
        if (dir.exists()) {
            if (!dir.isDirectory) {
                Timber.w("%s exists but is not a directory.", dir)
                return noAccess
            }
        } else {
            if (dir.mkdirs()) {
                Timber.i("Created directory %s", dir)
            } else {
                Timber.w("Failed to create directory %s", dir)
                return noAccess
            }
        }
        if (!dir.canRead()) {
            Timber.w("No read permission for directory %s", dir)
            return noAccess
        }
        if (!dir.canWrite()) {
            Timber.w("No write permission for directory %s", dir)
            return Pair(true, false)
        }
        return Pair(true, true)
    }

    /**
     * Makes a given filename safe by replacing special characters like slashes ("/" and "\")
     * with dashes ("-").
     *
     * @param name The filename in question.
     * @return The filename with special characters replaced by hyphens.
     */
    private fun fileSystemSafe(name: String?): String {
        if (name == null || name.trim { it <= ' ' }.isEmpty()) {
            return UNNAMED
        }
        var filename: String = name

        for (s in FILE_SYSTEM_UNSAFE) {
            filename = filename.replace(s, "-")
        }
        return filename
    }

    /**
     * Makes a given filename safe by replacing special characters like colons (":")
     * with dashes ("-").
     *
     * @param path The path of the directory in question.
     * @return The the directory name with special characters replaced by hyphens.
     */
    private fun fileSystemSafeDir(path: String?): String {
        var filepath = path
        if (filepath == null || filepath.trim { it <= ' ' }.isEmpty()) {
            return ""
        }
        for (s in FILE_SYSTEM_UNSAFE_DIR) {
            filepath = filepath!!.replace(s, "-")
        }
        return filepath!!
    }

    /**
     * Similar to [File.listFiles], but returns a sorted set.
     * Never returns `null`, instead a warning is logged, and an empty set is returned.
     */
    @JvmStatic
    fun listFiles(dir: AbstractFile): SortedSet<AbstractFile> {
        val files = dir.listFiles()
        return TreeSet(files.asList())
    }

    @JvmStatic
    fun listFiles(dir: File): SortedSet<File> {
        val files = dir.listFiles()
        if (files == null) {
            Timber.w("Failed to list children for %s", dir.path)
            return TreeSet()
        }
        return TreeSet(files.asList())
    }

    fun listMediaFiles(dir: AbstractFile): SortedSet<AbstractFile> {
        val files = listFiles(dir)
        val iterator = files.iterator()
        while (iterator.hasNext()) {
            val file = iterator.next()
            if (!file.isDirectory && !isMediaFile(file)) {
                iterator.remove()
            }
        }
        return files
    }

    private fun isMediaFile(file: AbstractFile): Boolean {
        val extension = getExtension(file.name)
        return MUSIC_FILE_EXTENSIONS.contains(extension) ||
            VIDEO_FILE_EXTENSIONS.contains(extension)
    }

    fun isPlaylistFile(file: File): Boolean {
        val extension = getExtension(file.name)
        return PLAYLIST_FILE_EXTENSIONS.contains(extension)
    }

    /**
     * Returns the extension (the substring after the last dot) of the given file. The dot
     * is not included in the returned extension.
     *
     * @param name The filename in question.
     * @return The extension, or an empty string if no extension is found.
     */
    fun getExtension(name: String): String {
        val index = name.lastIndexOf('.')
        return if (index == -1) "" else name.substring(index + 1).lowercase(Locale.ROOT)
    }

    /**
     * Returns the base name (the substring before the last dot) of the given file. The dot
     * is not included in the returned basename.
     *
     * @param name The filename in question.
     * @return The base name, or an empty string if no basename is found.
     */
    fun getBaseName(name: String): String {
        val index = name.lastIndexOf('.')
        return if (index == -1) name else name.substring(0, index)
    }

    /**
     * Returns the file name of a .partial file of the given file.
     *
     * @param name The filename in question.
     * @return The .partial file name
     */
    fun getPartialFile(name: String): String {
        return String.format(Locale.ROOT, "%s.partial.%s", getBaseName(name), getExtension(name))
    }

    fun getNameFromPath(path: String): String {
        return path.substringAfterLast('/')
    }

    fun getParentPath(path: String?): String? {
        if (path == null || !path.contains('/')) return null
        return path.substringBeforeLast('/')
    }

    fun getPinnedFile(name: String): String {
        val baseName = getBaseName(name)
        if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
            return "${getBaseName(baseName)}.${getExtension(name)}"
        }
        return name
    }

    /**
     * Returns the file name of a .complete file of the given file.
     *
     * @param name The filename in question.
     * @return The .complete file name
     */
    fun getCompleteFile(name: String): String {
        return String.format(Locale.ROOT, "%s.complete.%s", getBaseName(name), getExtension(name))
    }

    @JvmStatic
    fun <T : Serializable?> serialize(context: Context, obj: T, fileName: String): Boolean {
        val file = File(context.cacheDir, fileName)
        var out: ObjectOutputStream? = null
        return try {
            out = ObjectOutputStream(FileOutputStream(file))
            out.writeObject(obj)
            Timber.i("Serialized object to %s", file)
            true
        } catch (ignored: Exception) {
            Timber.w("Failed to serialize object to %s", file)
            false
        } finally {
            out.safeClose()
        }
    }

    @Suppress("UNCHECKED_CAST")
    @JvmStatic
    fun <T : Serializable?> deserialize(context: Context, fileName: String): T? {
        val file = File(context.cacheDir, fileName)
        if (!file.exists() || !file.isFile) {
            return null
        }
        var inStream: ObjectInputStream? = null
        return try {
            inStream = ObjectInputStream(FileInputStream(file))
            val readObject = inStream.readObject()
            val result = readObject as T
            Timber.i("Deserialized object from %s", file)
            result
        } catch (all: Throwable) {
            Timber.w(all, "Failed to deserialize object from %s", file)
            null
        } finally {
            inStream.safeClose()
        }
    }

    fun savePlaylist(playlistFile: File?, playlist: MusicDirectory, name: String) {
        val fw = FileWriter(playlistFile)
        val bw = BufferedWriter(fw)

        try {
            fw.write("#EXTM3U\n")
            for (e in playlist.getTracks()) {
                var filePath = getSongFile(e)

                if (!Storage.isPathExists(filePath)) {
                    val ext = getExtension(filePath)
                    val base = getBaseName(filePath)
                    filePath = "$base.complete.$ext"
                }
                fw.write(filePath + "\n")
            }
        } catch (e: IOException) {
            Timber.w("Failed to save playlist: %s", name)
            throw e
        } finally {
            bw.safeClose()
            fw.safeClose()
        }
    }

    @Throws(IOException::class)
    fun InputStream.copyWithProgress(
        out: OutputStream,
        onCopy: (totalBytesCopied: Long) -> Any
    ): Long {
        var bytesCopied: Long = 0
        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
        var bytes = read(buffer)
        while (bytes >= 0) {
            out.write(buffer, 0, bytes)
            bytesCopied += bytes
            onCopy(bytesCopied)
            bytes = read(buffer)
        }
        return bytesCopied
    }
}
