/*
 * Copyright (c) 2022 Auxio Project
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package org.oxycblt.auxio.music.extractor

import android.content.Context
import android.database.Cursor
import android.os.Build
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.queryCursor
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD

/**
 * The layer that loads music from the MediaStore database. This is an intermediate step in the
 * music loading process.
 * @author OxygenCobalt
 */
abstract class MediaStoreExtractor(
    private val context: Context,
    private val cacheDatabase: CacheExtractor
) {
    private var cursor: Cursor? = null

    private var idIndex = -1
    private var titleIndex = -1
    private var displayNameIndex = -1
    private var mimeTypeIndex = -1
    private var sizeIndex = -1
    private var dateAddedIndex = -1
    private var dateModifiedIndex = -1
    private var durationIndex = -1
    private var yearIndex = -1
    private var albumIndex = -1
    private var albumIdIndex = -1
    private var artistIndex = -1
    private var albumArtistIndex = -1

    private val settings = Settings(context)
    private val genreNamesMap = mutableMapOf<Long, String>()

    private val _volumes = mutableListOf<StorageVolume>()
    protected val volumes: List<StorageVolume>
        get() = _volumes

    /** Initialize this instance by making a query over the media database. */
    open fun init(): Cursor {
        logD("Initializing")
        val start = System.currentTimeMillis()

        cacheDatabase.init()

        val storageManager = context.getSystemServiceCompat(StorageManager::class)
        _volumes.addAll(storageManager.storageVolumesCompat)
        val dirs = settings.getMusicDirs(storageManager)

        val args = mutableListOf<String>()
        var selector = BASE_SELECTOR

        if (settings.excludeNonMusic) {
            selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
        }

        if (dirs.dirs.isNotEmpty()) {
            // Need to select for directories. The path query is the same, only difference is
            // the presence of a NOT.
            selector +=
                if (dirs.shouldInclude) {
                    logD("Need to select dirs (Include)")
                    " AND ("
                } else {
                    logD("Need to select dirs (Exclude)")
                    " AND NOT ("
                }

            // Each impl adds the directories that they want selected.
            for (i in dirs.dirs.indices) {
                if (addDirToSelectorArgs(dirs.dirs[i], args)) {
                    selector +=
                        if (i < dirs.dirs.lastIndex) {
                            "$dirSelector OR "
                        } else {
                            dirSelector
                        }
                }
            }

            selector += ')'
        }

        logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")

        val cursor =
            requireNotNull(
                    context.contentResolverSafe.queryCursor(
                        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                        projection,
                        selector,
                        args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
                .also { cursor = it }

        idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
        titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
        displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
        mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
        sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
        dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
        dateModifiedIndex =
            cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
        durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
        yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
        albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
        albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
        artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
        albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)

        logD("Song query succeeded [Projected total: ${cursor.count}]")

        logD("Assembling genre map")

        // Since we can't obtain the genre tag from a song query, we must construct
        // our own equivalent from genre database queries. Theoretically, this isn't
        // needed since MetadataLayer will fill this in for us, but I'd imagine there
        // are some obscure formats where genre support is only really covered by this,
        // so we are forced to bite the O(n^2) complexity here.
        context.contentResolverSafe.useQuery(
            MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
            arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
            val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
            val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)

            while (genreCursor.moveToNext()) {
                val id = genreCursor.getLong(idIndex)
                val name = genreCursor.getStringOrNull(nameIndex) ?: continue

                context.contentResolverSafe.useQuery(
                    MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
                    arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
                    val songIdIndex =
                        cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)

                    while (cursor.moveToNext()) {
                        // Assume that a song can't inhabit multiple genre entries, as I doubt
                        // Android is smart enough to separate genres.
                        genreNamesMap[cursor.getLong(songIdIndex)] = name
                    }
                }
            }
        }

        logD("Finished initialization in ${System.currentTimeMillis() - start}ms")

        return cursor
    }

    /** Finalize this instance by closing the cursor and finalizing the cache. */
    fun finalize(rawSongs: List<Song.Raw>) {
        cursor?.close()
        cursor = null

        cacheDatabase.finalize(rawSongs)
    }

    /**
     * Populate a [raw] with whatever the next value in the cursor is.
     *
     * This returns true if the song could be restored from cache, false if metadata had to be
     * re-extracted, and null if the cursor is exhausted.
     */
    fun populateRawSong(raw: Song.Raw): Boolean? {
        val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
        if (!cursor.moveToNext()) {
            logD("Cursor is exhausted")
            return null
        }

        // Populate the minimum required fields to maybe obtain a cache entry.
        populateFileData(cursor, raw)

        if (cacheDatabase.populateFromCache(raw)) {
            // We found a valid cache entry, no need to extract metadata.
            return true
        }

        populateMetadata(cursor, raw)

        // We had to freshly make this raw, return false
        return false
    }

    /**
     * The projection to use when querying media. Add version-specific columns here in an
     * implementation.
     */
    protected open val projection: Array<String>
        get() =
            arrayOf(
                // These columns are guaranteed to work on all versions of android
                MediaStore.Audio.AudioColumns._ID,
                MediaStore.Audio.AudioColumns.DATE_ADDED,
                MediaStore.Audio.AudioColumns.DATE_MODIFIED,
                MediaStore.Audio.AudioColumns.DISPLAY_NAME,
                MediaStore.Audio.AudioColumns.SIZE,
                MediaStore.Audio.AudioColumns.DURATION,
                MediaStore.Audio.AudioColumns.MIME_TYPE,
                MediaStore.Audio.AudioColumns.TITLE,
                MediaStore.Audio.AudioColumns.YEAR,
                MediaStore.Audio.AudioColumns.ALBUM,
                MediaStore.Audio.AudioColumns.ALBUM_ID,
                MediaStore.Audio.AudioColumns.ARTIST,
                AUDIO_COLUMN_ALBUM_ARTIST)

    protected abstract val dirSelector: String
    protected abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean

    /**
     * Populate the "file data" of the cursor, or data that is required to access a cache entry or
     * makes no sense to cache. This includes database IDs, modification dates,
     */
    protected open fun populateFileData(cursor: Cursor, raw: Song.Raw) {
        raw.mediaStoreId = cursor.getLong(idIndex)
        raw.dateAdded = cursor.getLong(dateAddedIndex)
        raw.dateModified = cursor.getLong(dateAddedIndex)

        // Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
        // from the android system.
        raw.fileName = cursor.getStringOrNull(displayNameIndex)
        raw.extensionMimeType = cursor.getString(mimeTypeIndex)

        raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
    }

    /** Extract cursor metadata into [raw]. */
    protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
        raw.name = cursor.getString(titleIndex)

        raw.size = cursor.getLong(sizeIndex)
        raw.durationMs = cursor.getLong(durationIndex)

        raw.date = cursor.getIntOrNull(yearIndex)?.toDate()

        // A non-existent album name should theoretically be the name of the folder it contained
        // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
        // file is not actually in the root internal storage directory. We can't do anything to
        // fix this, really.
        raw.albumName = cursor.getString(albumIndex)

        // Android does not make a non-existent artist tag null, it instead fills it in
        // as <unknown>, which makes absolutely no sense given how other fields default
        // to null if they are not present. If this field is <unknown>, null it so that
        // it's easier to handle later.
        val artist = cursor.getString(artistIndex)
        if (artist != MediaStore.UNKNOWN_STRING) {
            raw.artistNames = listOf(artist)
        }

        // The album artist field is nullable and never has placeholder values.
        cursor.getStringOrNull(albumArtistIndex)?.let { raw.albumArtistNames = listOf(it) }

        // Get the genre value we had to query for in initialization
        genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
    }

    companion object {
        /**
         * The album_artist MediaStore field has existed since at least API 21, but until API 30 it
         * was a proprietary extension for Google Play Music and was not documented. Since this
         * field probably works on all versions Auxio supports, we suppress the warning about using
         * a possibly-unsupported constant.
         */
        @Suppress("InlinedApi")
        private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST

        /**
         * External has existed since at least API 21, but no constant existed for it until API 29.
         * This constant is safe to use.
         */
        @Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL

        /**
         * The base selector that works across all versions of android. Does not exclude
         * directories.
         */
        private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
    }
}

// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.

/**
 * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 21
 * onwards to API 29.
 * @author OxygenCobalt
 */
class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
    MediaStoreExtractor(context, cacheDatabase) {
    private var trackIndex = -1
    private var dataIndex = -1

    override fun init(): Cursor {
        val cursor = super.init()
        trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
        dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
        return cursor
    }

    override val projection: Array<String>
        get() =
            super.projection +
                arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA)

    override val dirSelector: String
        get() = "${MediaStore.Audio.Media.DATA} LIKE ?"

    override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
        // Generate an equivalent DATA value from the volume directory and the relative path.
        args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
        return true
    }

    override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
        super.populateFileData(cursor, raw)

        // DATA is equivalent to the absolute path of the file.
        val data = cursor.getString(dataIndex)

        // On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
        // that this only applies to below API 29, as beyond API 29, this field not being
        // present would completely break the scoped storage system. Fill it in with DATA
        // if it's not available.
        if (raw.fileName == null) {
            raw.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
        }

        // Find the volume that transforms the DATA field into a relative path. This is
        // the volume and relative path we will use.
        val rawPath = data.substringBeforeLast(File.separatorChar)
        for (volume in volumes) {
            val volumePath = volume.directoryCompat ?: continue
            val strippedPath = rawPath.removePrefix(volumePath)
            if (strippedPath != rawPath) {
                raw.directory = Directory.from(volume, strippedPath)
                break
            }
        }
    }

    override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
        super.populateMetadata(cursor, raw)

        val rawTrack = cursor.getIntOrNull(trackIndex)
        if (rawTrack != null) {
            rawTrack.unpackTrackNo()?.let { raw.track = it }
            rawTrack.unpackDiscNo()?.let { raw.disc = it }
        }
    }
}

/**
 * A [MediaStoreExtractor] that selects directories and builds paths using the modern volume fields
 * available from API 29 onwards.
 * @author OxygenCobalt
 */
@RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
    MediaStoreExtractor(context, cacheDatabase) {
    private var volumeIndex = -1
    private var relativePathIndex = -1

    override fun init(): Cursor {
        val cursor = super.init()

        volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
        relativePathIndex =
            cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)

        return cursor
    }

    override val projection: Array<String>
        get() =
            super.projection +
                arrayOf(
                    MediaStore.Audio.AudioColumns.VOLUME_NAME,
                    MediaStore.Audio.AudioColumns.RELATIVE_PATH)

    override val dirSelector: String
        get() =
            "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
                "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"

    override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
        // Leverage new the volume field when selecting our directories.
        args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
        args.add("${dir.relativePath}%")
        return true
    }

    override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
        super.populateFileData(cursor, raw)

        val volumeName = cursor.getString(volumeIndex)
        val relativePath = cursor.getString(relativePathIndex)

        // Find the StorageVolume whose MediaStore name corresponds to this song.
        // This is what we use for the Directory's volume.
        val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
        if (volume != null) {
            raw.directory = Directory.from(volume, relativePath)
        }
    }
}

/**
 * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at
 * least API 29.
 * @author OxygenCobalt
 */
@RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
    BaseApi29MediaStoreExtractor(context, cacheDatabase) {
    private var trackIndex = -1

    override fun init(): Cursor {
        val cursor = super.init()
        trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
        return cursor
    }

    override val projection: Array<String>
        get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)

    override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
        super.populateMetadata(cursor, raw)

        // This backend is volume-aware, but does not support the modern track fields.
        // Use the old field instead.
        val rawTrack = cursor.getIntOrNull(trackIndex)
        if (rawTrack != null) {
            rawTrack.unpackTrackNo()?.let { raw.track = it }
            rawTrack.unpackDiscNo()?.let { raw.disc = it }
        }
    }
}

/**
 * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at
 * least API 30.
 * @author OxygenCobalt
 */
@RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
    BaseApi29MediaStoreExtractor(context, cacheDatabase) {
    private var trackIndex: Int = -1
    private var discIndex: Int = -1

    override fun init(): Cursor {
        val cursor = super.init()
        trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
        discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
        return cursor
    }

    override val projection: Array<String>
        get() =
            super.projection +
                arrayOf(
                    MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
                    MediaStore.Audio.AudioColumns.DISC_NUMBER)

    override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
        super.populateMetadata(cursor, raw)

        // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
        // the tag itself, which is to say that it is formatted as NN/TT tracks, where
        // N is the number and T is the total. Parse the number while leaving out the
        // total, as we have no use for it.
        cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
        cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
    }
}
