package cy.hexor.furumi.data.local

import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import com.squareup.moshi.Moshi
import cy.hexor.furumi.data.remote.model.OfflineManifestPlaylistResponse
import cy.hexor.furumi.data.remote.model.OfflineManifestTrackResponse
import cy.hexor.furumi.domain.model.ArtistCard
import cy.hexor.furumi.domain.model.ArtistDetail
import cy.hexor.furumi.domain.model.ArtistPage
import cy.hexor.furumi.domain.model.PlaylistCard
import cy.hexor.furumi.domain.model.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseCard
import cy.hexor.furumi.domain.model.ReleaseDetail
import cy.hexor.furumi.domain.model.SearchResults
import cy.hexor.furumi.domain.model.TrackCard
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class OfflineLibraryStore @Inject constructor(
    @ApplicationContext context: Context,
    moshi: Moshi
) {
    private val dbHelper = OfflineDbHelper(context.applicationContext)
    private val trackAdapter = moshi.adapter(TrackCard::class.java)

    fun upsertTracks(tracks: List<TrackCard>) {
        if (tracks.isEmpty()) return
        dbHelper.writableDatabase.transaction {
            tracks.forEach { track -> upsertTrackLocked(this, track) }
        }
    }

    fun upsertManifestTracks(tracks: List<OfflineManifestTrackResponse>) {
        if (tracks.isEmpty()) return
        dbHelper.writableDatabase.transaction {
            tracks.forEach { manifest ->
                val existing = getTrackRecordLocked(this, manifest.id)
                val audioChanged = existing?.audioHash != null &&
                    existing.audioHash != manifest.audioHash
                val coverChanged = existing?.coverHash != null &&
                    existing.coverHash != manifest.coverHash
                val values = ContentValues().apply {
                    put(COL_TRACK_ID, manifest.id)
                    put(COL_UPDATED_AT, manifest.updatedAt)
                    put(COL_STREAM_URL, manifest.streamUrl)
                    put(COL_REMOTE_STREAM_URL, manifest.streamUrl)
                    put(COL_AUDIO_HASH, manifest.audioHash)
                    put(COL_AUDIO_SIZE_BYTES, manifest.audioSizeBytes)
                    put(COL_AUDIO_MIME_TYPE, manifest.audioMimeType)
                    put(COL_AUDIO_UPDATED_AT, manifest.audioUpdatedAt)
                    put(COL_COVER_URL, manifest.coverUrl)
                    put(COL_REMOTE_COVER_URL, manifest.coverUrl)
                    put(COL_COVER_HASH, manifest.coverHash)
                    put(COL_COVER_UPDATED_AT, manifest.coverUpdatedAt)
                    if (audioChanged) {
                        put(COL_AUDIO_STATUS, STATUS_STALE)
                    }
                    if (coverChanged) {
                        put(COL_COVER_STATUS, STATUS_STALE)
                    }
                }
                insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, manifest.id, values)
            }
        }
    }

    fun upsertPlaylistDetail(detail: PlaylistDetail) {
        dbHelper.writableDatabase.transaction {
            detail.tracks.forEach { track -> upsertTrackLocked(this, track) }
            val playlist = detail.playlist
            insertOrUpdate(TABLE_PLAYLISTS, COL_PLAYLIST_ID, playlist.id, ContentValues().apply {
                put(COL_PLAYLIST_ID, playlist.id)
                put(COL_PLAYLIST_TITLE, playlist.title)
                put(COL_PLAYLIST_DESCRIPTION, detail.description)
                put(COL_PLAYLIST_UPDATED_AT, System.currentTimeMillis().toString())
                put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
                put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
                put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
                put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
                put(COL_PLAYLIST_KIND, playlist.kind)
                put(COL_PLAYLIST_TRACK_IDS, detail.tracks.map { it.id }.toJsonArrayString())
            })
        }
    }

    fun replacePlaylists(playlists: List<OfflineManifestPlaylistResponse>) {
        dbHelper.writableDatabase.transaction {
            delete(TABLE_PLAYLISTS, null, null)
            playlists.forEach { playlist ->
                insert(TABLE_PLAYLISTS, null, ContentValues().apply {
                    put(COL_PLAYLIST_ID, playlist.id)
                    put(COL_PLAYLIST_TITLE, playlist.title)
                    put(COL_PLAYLIST_DESCRIPTION, playlist.description)
                    put(COL_PLAYLIST_UPDATED_AT, playlist.updatedAt)
                    put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
                    put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
                    put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
                    put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
                    put(COL_PLAYLIST_KIND, playlist.kind)
                    put(COL_PLAYLIST_TRACK_IDS, playlist.trackIds.toJsonArrayString())
                })
            }
        }
    }

    fun replaceLikedTrackIds(trackIds: Set<Long>) {
        dbHelper.writableDatabase.transaction {
            delete(TABLE_LIKES, null, null)
            trackIds.forEach { trackId ->
                insert(TABLE_LIKES, null, ContentValues().apply {
                    put(COL_LIKE_TRACK_ID, trackId)
                })
            }
        }
    }

    fun getLikedTrackIds(): Set<Long> {
        return dbHelper.readableDatabase.query(
            TABLE_LIKES,
            arrayOf(COL_LIKE_TRACK_ID),
            null,
            null,
            null,
            null,
            null
        ).useCursor { cursor ->
            buildSet {
                while (cursor.moveToNext()) add(cursor.getLong(COL_LIKE_TRACK_ID))
            }
        }
    }

    fun getPlaylists(): List<PlaylistCard> {
        return dbHelper.readableDatabase.query(
            TABLE_PLAYLISTS,
            null,
            null,
            null,
            null,
            null,
            "$COL_PLAYLIST_IS_OWN DESC, $COL_PLAYLIST_TITLE COLLATE NOCASE"
        ).useCursor { cursor ->
            buildList {
                while (cursor.moveToNext()) {
                    val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
                    add(cursor.toPlaylistCard(trackIds.size))
                }
            }
        }
    }

    fun getPlaylistDetail(playlistId: Long): PlaylistDetail? {
        return dbHelper.readableDatabase.query(
            TABLE_PLAYLISTS,
            null,
            "$COL_PLAYLIST_ID = ?",
            arrayOf(playlistId.toString()),
            null,
            null,
            null,
            "1"
        ).useCursor { cursor ->
            if (!cursor.moveToFirst()) return@useCursor null
            val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
            PlaylistDetail(
                playlist = cursor.toPlaylistCard(trackIds.size),
                description = cursor.getStringOrNull(COL_PLAYLIST_DESCRIPTION),
                tracks = getTracksByIds(trackIds)
            )
        }
    }

    fun search(query: String, limit: Int): SearchResults {
        val normalized = query.trim().lowercase()
        if (normalized.isBlank()) {
            return SearchResults(emptyList(), emptyList(), emptyList())
        }
        val pattern = "%$normalized%"
        val tracks = dbHelper.readableDatabase.query(
            TABLE_TRACKS,
            null,
            "$COL_TITLE_SORT LIKE ? OR lower($COL_RELEASE_TITLE) LIKE ? OR $COL_ARTISTS_SORT LIKE ?",
            arrayOf(pattern, pattern, pattern),
            null,
            null,
            "$COL_PLAY_COUNT DESC, $COL_LAST_ACCESSED_AT DESC, $COL_TITLE_SORT",
            limit.toString()
        ).useCursor { cursor ->
            buildList {
                while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia = true)?.let(::add)
            }
        }
        val artistMatches = tracks
            .flatMap { track ->
                track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
                    track.artists.map { ArtistSeed(it) }
                }
            }
            .distinctBy { it.artistIdOrName }
            .take(limit)
            .map { seed ->
                ArtistCard(
                    id = seed.artistId,
                    name = seed.artistName,
                    imageUrl = null,
                    releaseCount = 0,
                    trackCount = tracks.count { track ->
                        track.artistRefs.any { it.id == seed.artistId } ||
                            track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
                    }
                )
            }
        return SearchResults(
            artistMatches = artistMatches,
            trackArtists = artistMatches,
            tracks = tracks
        )
    }

    fun getArtists(page: Int, limit: Int): ArtistPage {
        val tracks = getAllTracks(preferLocalMedia = true)
        val artists = tracks
            .flatMap { track ->
                track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
                    track.artists.map { ArtistSeed(it) }
                }
            }
            .groupBy { it.artistIdOrName }
            .map { (_, seeds) ->
                val seed = seeds.first()
                ArtistCard(
                    id = seed.artistId,
                    name = seed.artistName,
                    imageUrl = null,
                    releaseCount = tracks
                        .filter { track ->
                            track.artistRefs.any { it.id == seed.artistId } ||
                                track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
                        }
                        .mapNotNull { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() }
                        .distinct()
                        .size,
                    trackCount = seeds.size
                )
            }
            .sortedBy { it.name.lowercase() }
        val safePage = page.coerceAtLeast(1)
        val from = ((safePage - 1) * limit).coerceAtMost(artists.size)
        val to = (from + limit).coerceAtMost(artists.size)
        return ArtistPage(
            items = artists.subList(from, to),
            total = artists.size.toLong(),
            page = safePage,
            perPage = limit,
            hasMore = to < artists.size
        )
    }

    fun getArtistDetail(artistId: Long): ArtistDetail? {
        val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
            track.artistRefs.any { it.id == artistId } ||
                (artistId < 0 && track.artists.any { it.hashCode().toLong() == -artistId })
        }
        if (tracks.isEmpty()) return null
        val name = tracks.firstNotNullOfOrNull { track ->
            track.artistRefs.firstOrNull { it.id == artistId }?.name
        } ?: tracks.first().artists.firstOrNull().orEmpty()
        val releases = tracks
            .groupBy { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() ?: it.id }
            .map { (releaseId, releaseTracks) ->
                ReleaseCard(
                    id = releaseId,
                    title = releaseTracks.first().releaseTitle ?: "Unknown release",
                    releaseType = null,
                    year = releaseTracks.firstNotNullOfOrNull { it.releaseYear },
                    coverUrl = releaseTracks.firstNotNullOfOrNull { it.coverUrl },
                    trackCount = releaseTracks.size
                )
            }
        return ArtistDetail(
            artist = ArtistCard(
                id = artistId,
                name = name.ifBlank { "Unknown artist" },
                imageUrl = null,
                releaseCount = releases.size,
                trackCount = tracks.size
            ),
            releases = releases,
            topTracks = tracks,
            featuredTracks = emptyList()
        )
    }

    fun getReleaseDetail(releaseId: Long): ReleaseDetail? {
        val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
            track.releaseId == releaseId
        }
        if (tracks.isEmpty()) return null
        val release = ReleaseCard(
            id = releaseId,
            title = tracks.first().releaseTitle ?: "Unknown release",
            releaseType = null,
            year = tracks.firstNotNullOfOrNull { it.releaseYear },
            coverUrl = tracks.firstNotNullOfOrNull { it.coverUrl },
            trackCount = tracks.size
        )
        val artists = tracks
            .flatMap { it.artistRefs }
            .distinctBy { it.id }
            .map {
                ArtistCard(
                    id = it.id,
                    name = it.name,
                    imageUrl = null,
                    releaseCount = 1,
                    trackCount = tracks.count { track -> track.artistRefs.any { artist -> artist.id == it.id } }
                )
            }
        return ReleaseDetail(release = release, artists = artists, tracks = tracks)
    }

    fun getTrack(trackId: Long, preferLocalMedia: Boolean = true): TrackCard? {
        return dbHelper.readableDatabase.query(
            TABLE_TRACKS,
            null,
            "$COL_TRACK_ID = ?",
            arrayOf(trackId.toString()),
            null,
            null,
            null,
            "1"
        ).useCursor { cursor ->
            if (cursor.moveToFirst()) cursor.toTrackCard(preferLocalMedia) else null
        }
    }

    fun getTracksByIds(trackIds: List<Long>, preferLocalMedia: Boolean = true): List<TrackCard> {
        if (trackIds.isEmpty()) return emptyList()
        return trackIds.mapNotNull { getTrack(it, preferLocalMedia) }
    }

    fun markTrackAccessed(trackId: Long) {
        val now = System.currentTimeMillis()
        dbHelper.writableDatabase.execSQL(
            "UPDATE $TABLE_TRACKS SET $COL_LAST_ACCESSED_AT = ?, $COL_PLAY_COUNT = $COL_PLAY_COUNT + 1 WHERE $COL_TRACK_ID = ?",
            arrayOf(now, trackId)
        )
    }

    fun markAudioCached(trackId: Long, path: String, sizeBytes: Long) {
        updateMediaColumns(
            trackId = trackId,
            localPathColumn = COL_LOCAL_AUDIO_PATH,
            statusColumn = COL_AUDIO_STATUS,
            bytesColumn = COL_CACHED_AUDIO_BYTES,
            path = path,
            sizeBytes = sizeBytes
        )
    }

    fun markCoverCached(trackId: Long, path: String, sizeBytes: Long) {
        updateMediaColumns(
            trackId = trackId,
            localPathColumn = COL_LOCAL_COVER_PATH,
            statusColumn = COL_COVER_STATUS,
            bytesColumn = COL_CACHED_COVER_BYTES,
            path = path,
            sizeBytes = sizeBytes
        )
    }

    fun markAudioRemoved(trackId: Long) {
        dbHelper.writableDatabase.update(
            TABLE_TRACKS,
            ContentValues().apply {
                putNull(COL_LOCAL_AUDIO_PATH)
                put(COL_AUDIO_STATUS, STATUS_NONE)
                put(COL_CACHED_AUDIO_BYTES, 0L)
            },
            "$COL_TRACK_ID = ?",
            arrayOf(trackId.toString())
        )
    }

    fun markCoverRemoved(trackId: Long) {
        dbHelper.writableDatabase.update(
            TABLE_TRACKS,
            ContentValues().apply {
                putNull(COL_LOCAL_COVER_PATH)
                put(COL_COVER_STATUS, STATUS_NONE)
                put(COL_CACHED_COVER_BYTES, 0L)
            },
            "$COL_TRACK_ID = ?",
            arrayOf(trackId.toString())
        )
    }

    fun clearCachedMedia() {
        dbHelper.writableDatabase.update(
            TABLE_TRACKS,
            ContentValues().apply {
                putNull(COL_LOCAL_AUDIO_PATH)
                put(COL_AUDIO_STATUS, STATUS_NONE)
                put(COL_CACHED_AUDIO_BYTES, 0L)
                putNull(COL_LOCAL_COVER_PATH)
                put(COL_COVER_STATUS, STATUS_NONE)
                put(COL_CACHED_COVER_BYTES, 0L)
            },
            null,
            null
        )
    }

    fun cachedAudioEntriesForEviction(): List<OfflineCachedMediaEntry> {
        return dbHelper.readableDatabase.query(
            TABLE_TRACKS,
            arrayOf(
                COL_TRACK_ID,
                COL_LOCAL_AUDIO_PATH,
                COL_CACHED_AUDIO_BYTES,
                COL_LAST_ACCESSED_AT,
                COL_PLAY_COUNT
            ),
            "$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL",
            arrayOf(STATUS_READY),
            null,
            null,
            "$COL_LAST_ACCESSED_AT ASC, $COL_PLAY_COUNT ASC"
        ).useCursor { cursor ->
            buildList {
                while (cursor.moveToNext()) {
                    add(
                        OfflineCachedMediaEntry(
                            trackId = cursor.getLong(COL_TRACK_ID),
                            path = cursor.getString(COL_LOCAL_AUDIO_PATH),
                            sizeBytes = cursor.getLong(COL_CACHED_AUDIO_BYTES),
                            lastAccessedAtMs = cursor.getLong(COL_LAST_ACCESSED_AT),
                            playCount = cursor.getInt(COL_PLAY_COUNT)
                        )
                    )
                }
            }
        }
    }

    fun staleMediaPaths(): List<OfflineStaleMediaPath> {
        return dbHelper.readableDatabase.query(
            TABLE_TRACKS,
            arrayOf(COL_TRACK_ID, COL_LOCAL_AUDIO_PATH, COL_AUDIO_STATUS, COL_LOCAL_COVER_PATH, COL_COVER_STATUS),
            "$COL_AUDIO_STATUS = ? OR $COL_COVER_STATUS = ?",
            arrayOf(STATUS_STALE, STATUS_STALE),
            null,
            null,
            null
        ).useCursor { cursor ->
            buildList {
                while (cursor.moveToNext()) {
                    if (cursor.getStringOrNull(COL_AUDIO_STATUS) == STATUS_STALE) {
                        cursor.getStringOrNull(COL_LOCAL_AUDIO_PATH)?.let { path ->
                            add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = true))
                        }
                    }
                    if (cursor.getStringOrNull(COL_COVER_STATUS) == STATUS_STALE) {
                        cursor.getStringOrNull(COL_LOCAL_COVER_PATH)?.let { path ->
                            add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = false))
                        }
                    }
                }
            }
        }
    }

    fun storageStats(): OfflineStorageStats {
        return dbHelper.readableDatabase.rawQuery(
            "SELECT COALESCE(SUM($COL_CACHED_AUDIO_BYTES + $COL_CACHED_COVER_BYTES), 0), " +
                "COALESCE(SUM(CASE WHEN $COL_AUDIO_STATUS = '$STATUS_READY' THEN 1 ELSE 0 END), 0) " +
                "FROM $TABLE_TRACKS",
            null
        ).useCursor { cursor ->
            if (cursor.moveToFirst()) {
                OfflineStorageStats(
                    usedBytes = cursor.getLong(0),
                    cachedTrackCount = cursor.getInt(1)
                )
            } else {
                OfflineStorageStats(0L, 0)
            }
        }
    }

    private fun getAllTracks(preferLocalMedia: Boolean): List<TrackCard> {
        return dbHelper.readableDatabase.query(
            TABLE_TRACKS,
            null,
            "$COL_TRACK_JSON IS NOT NULL",
            null,
            null,
            null,
            "$COL_TITLE_SORT"
        ).useCursor { cursor ->
            buildList {
                while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia)?.let(::add)
            }
        }
    }

    private fun upsertTrackLocked(db: SQLiteDatabase, track: TrackCard) {
        val existing = getTrackRecordLocked(db, track.id)
        val storedTrack = track.copy(
            streamUrl = track.remoteStreamUrl.ifBlank { track.streamUrl },
            coverUrl = track.remoteCoverUrl ?: track.coverUrl,
            localAudioPath = null,
            localCoverPath = null,
            isAudioCached = false
        )
        val values = ContentValues().apply {
            put(COL_TRACK_ID, track.id)
            put(COL_TITLE, track.title)
            put(COL_TITLE_SORT, track.title.lowercase())
            put(COL_ARTISTS_SORT, track.artists.joinToString(" ").lowercase())
            put(COL_RELEASE_ID, track.releaseId)
            put(COL_RELEASE_TITLE, track.releaseTitle)
            put(COL_RELEASE_YEAR, track.releaseYear)
            put(COL_DURATION_SECONDS, track.durationSeconds)
            put(COL_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
            put(COL_REMOTE_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
            put(COL_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
            put(COL_REMOTE_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
            put(COL_TRACK_JSON, trackAdapter.toJson(storedTrack))
            put(COL_UPDATED_AT, track.metadataUpdatedAt)
            put(COL_AUDIO_HASH, track.audioHash)
            put(COL_AUDIO_SIZE_BYTES, track.audioSizeBytes)
            put(COL_AUDIO_MIME_TYPE, track.audioMimeType)
            put(COL_AUDIO_UPDATED_AT, track.audioUpdatedAt)
            put(COL_COVER_HASH, track.coverHash)
            put(COL_COVER_UPDATED_AT, track.coverUpdatedAt)
            if (existing == null) {
                put(COL_AUDIO_STATUS, STATUS_NONE)
                put(COL_COVER_STATUS, STATUS_NONE)
                put(COL_CACHED_AUDIO_BYTES, 0L)
                put(COL_CACHED_COVER_BYTES, 0L)
                put(COL_LAST_ACCESSED_AT, 0L)
                put(COL_PLAY_COUNT, 0)
            }
        }
        db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
    }

    private fun getTrackRecordLocked(db: SQLiteDatabase, trackId: Long): OfflineTrackRecord? {
        return db.query(
            TABLE_TRACKS,
            arrayOf(COL_AUDIO_HASH, COL_COVER_HASH),
            "$COL_TRACK_ID = ?",
            arrayOf(trackId.toString()),
            null,
            null,
            null,
            "1"
        ).useCursor { cursor ->
            if (cursor.moveToFirst()) {
                OfflineTrackRecord(
                    audioHash = cursor.getStringOrNull(COL_AUDIO_HASH),
                    coverHash = cursor.getStringOrNull(COL_COVER_HASH)
                )
            } else {
                null
            }
        }
    }

    private fun updateMediaColumns(
        trackId: Long,
        localPathColumn: String,
        statusColumn: String,
        bytesColumn: String,
        path: String,
        sizeBytes: Long
    ) {
        dbHelper.writableDatabase.update(
            TABLE_TRACKS,
            ContentValues().apply {
                put(localPathColumn, path)
                put(statusColumn, STATUS_READY)
                put(bytesColumn, sizeBytes.coerceAtLeast(0L))
                put(COL_LAST_ACCESSED_AT, System.currentTimeMillis())
            },
            "$COL_TRACK_ID = ?",
            arrayOf(trackId.toString())
        )
    }

    private fun Cursor.toTrackCard(preferLocalMedia: Boolean): TrackCard? {
        val json = getStringOrNull(COL_TRACK_JSON) ?: return null
        val base = runCatching { trackAdapter.fromJson(json) }.getOrNull() ?: return null
        val localAudioPath = getStringOrNull(COL_LOCAL_AUDIO_PATH)
            ?.takeIf { getStringOrNull(COL_AUDIO_STATUS) == STATUS_READY && File(it).exists() }
        val localCoverPath = getStringOrNull(COL_LOCAL_COVER_PATH)
            ?.takeIf { getStringOrNull(COL_COVER_STATUS) == STATUS_READY && File(it).exists() }
        val remoteStreamUrl = getStringOrNull(COL_REMOTE_STREAM_URL)
            ?: base.remoteStreamUrl.ifBlank { base.streamUrl }
        val remoteCoverUrl = getStringOrNull(COL_REMOTE_COVER_URL) ?: base.remoteCoverUrl
        return base.copy(
            streamUrl = if (preferLocalMedia && localAudioPath != null) {
                Uri.fromFile(File(localAudioPath)).toString()
            } else {
                remoteStreamUrl
            },
            coverUrl = if (preferLocalMedia && localCoverPath != null) {
                Uri.fromFile(File(localCoverPath)).toString()
            } else {
                remoteCoverUrl
            },
            remoteStreamUrl = remoteStreamUrl,
            remoteCoverUrl = remoteCoverUrl,
            metadataUpdatedAt = getStringOrNull(COL_UPDATED_AT) ?: base.metadataUpdatedAt,
            audioHash = getStringOrNull(COL_AUDIO_HASH) ?: base.audioHash,
            audioSizeBytes = getLongOrNull(COL_AUDIO_SIZE_BYTES) ?: base.audioSizeBytes,
            audioMimeType = getStringOrNull(COL_AUDIO_MIME_TYPE) ?: base.audioMimeType,
            audioUpdatedAt = getStringOrNull(COL_AUDIO_UPDATED_AT) ?: base.audioUpdatedAt,
            coverHash = getStringOrNull(COL_COVER_HASH) ?: base.coverHash,
            coverUpdatedAt = getStringOrNull(COL_COVER_UPDATED_AT) ?: base.coverUpdatedAt,
            localAudioPath = localAudioPath,
            localCoverPath = localCoverPath,
            isAudioCached = localAudioPath != null
        )
    }

    private fun Cursor.toPlaylistCard(trackCount: Int): PlaylistCard {
        return PlaylistCard(
            id = getLong(COL_PLAYLIST_ID),
            title = getString(COL_PLAYLIST_TITLE),
            trackCount = trackCount,
            isPublic = getInt(COL_PLAYLIST_IS_PUBLIC) == 1,
            isOwn = getInt(COL_PLAYLIST_IS_OWN) == 1,
            ownerName = getStringOrNull(COL_PLAYLIST_OWNER_NAME),
            isSaved = getInt(COL_PLAYLIST_IS_SAVED) == 1,
            kind = getStringOrNull(COL_PLAYLIST_KIND)
        )
    }

    private class OfflineDbHelper(context: Context) : SQLiteOpenHelper(
        context,
        DB_NAME,
        null,
        DB_VERSION
    ) {
        override fun onCreate(db: SQLiteDatabase) {
            db.execSQL(
                """
                CREATE TABLE $TABLE_TRACKS (
                    $COL_TRACK_ID INTEGER PRIMARY KEY,
                    $COL_TITLE TEXT,
                    $COL_TITLE_SORT TEXT,
                    $COL_ARTISTS_SORT TEXT,
                    $COL_RELEASE_ID INTEGER,
                    $COL_RELEASE_TITLE TEXT,
                    $COL_RELEASE_YEAR INTEGER,
                    $COL_DURATION_SECONDS INTEGER,
                    $COL_COVER_URL TEXT,
                    $COL_REMOTE_COVER_URL TEXT,
                    $COL_STREAM_URL TEXT,
                    $COL_REMOTE_STREAM_URL TEXT,
                    $COL_TRACK_JSON TEXT,
                    $COL_UPDATED_AT TEXT,
                    $COL_AUDIO_HASH TEXT,
                    $COL_AUDIO_SIZE_BYTES INTEGER,
                    $COL_AUDIO_MIME_TYPE TEXT,
                    $COL_AUDIO_UPDATED_AT TEXT,
                    $COL_COVER_HASH TEXT,
                    $COL_COVER_UPDATED_AT TEXT,
                    $COL_LOCAL_AUDIO_PATH TEXT,
                    $COL_AUDIO_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
                    $COL_LOCAL_COVER_PATH TEXT,
                    $COL_COVER_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
                    $COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
                    $COL_CACHED_COVER_BYTES INTEGER NOT NULL DEFAULT 0,
                    $COL_LAST_ACCESSED_AT INTEGER NOT NULL DEFAULT 0,
                    $COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0
                )
                """.trimIndent()
            )
            db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)")
            db.execSQL("CREATE INDEX idx_offline_tracks_release ON $TABLE_TRACKS($COL_RELEASE_ID)")
            db.execSQL("CREATE INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)")
            db.execSQL(
                """
                CREATE TABLE $TABLE_PLAYLISTS (
                    $COL_PLAYLIST_ID INTEGER PRIMARY KEY,
                    $COL_PLAYLIST_TITLE TEXT NOT NULL,
                    $COL_PLAYLIST_DESCRIPTION TEXT,
                    $COL_PLAYLIST_UPDATED_AT TEXT,
                    $COL_PLAYLIST_IS_OWN INTEGER NOT NULL DEFAULT 0,
                    $COL_PLAYLIST_OWNER_NAME TEXT,
                    $COL_PLAYLIST_IS_PUBLIC INTEGER NOT NULL DEFAULT 0,
                    $COL_PLAYLIST_IS_SAVED INTEGER NOT NULL DEFAULT 0,
                    $COL_PLAYLIST_KIND TEXT,
                    $COL_PLAYLIST_TRACK_IDS TEXT NOT NULL DEFAULT '[]'
                )
                """.trimIndent()
            )
            db.execSQL(
                """
                CREATE TABLE $TABLE_LIKES (
                    $COL_LIKE_TRACK_ID INTEGER PRIMARY KEY
                )
                """.trimIndent()
            )
        }

        override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
            db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES")
            db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS")
            db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS")
            onCreate(db)
        }
    }

    private companion object {
        const val DB_NAME = "furumi_offline.db"
        const val DB_VERSION = 1

        const val TABLE_TRACKS = "offline_tracks"
        const val TABLE_PLAYLISTS = "offline_playlists"
        const val TABLE_LIKES = "offline_likes"

        const val COL_TRACK_ID = "track_id"
        const val COL_TITLE = "title"
        const val COL_TITLE_SORT = "title_sort"
        const val COL_ARTISTS_SORT = "artists_sort"
        const val COL_RELEASE_ID = "release_id"
        const val COL_RELEASE_TITLE = "release_title"
        const val COL_RELEASE_YEAR = "release_year"
        const val COL_DURATION_SECONDS = "duration_seconds"
        const val COL_COVER_URL = "cover_url"
        const val COL_REMOTE_COVER_URL = "remote_cover_url"
        const val COL_STREAM_URL = "stream_url"
        const val COL_REMOTE_STREAM_URL = "remote_stream_url"
        const val COL_TRACK_JSON = "track_json"
        const val COL_UPDATED_AT = "updated_at"
        const val COL_AUDIO_HASH = "audio_hash"
        const val COL_AUDIO_SIZE_BYTES = "audio_size_bytes"
        const val COL_AUDIO_MIME_TYPE = "audio_mime_type"
        const val COL_AUDIO_UPDATED_AT = "audio_updated_at"
        const val COL_COVER_HASH = "cover_hash"
        const val COL_COVER_UPDATED_AT = "cover_updated_at"
        const val COL_LOCAL_AUDIO_PATH = "local_audio_path"
        const val COL_AUDIO_STATUS = "audio_status"
        const val COL_LOCAL_COVER_PATH = "local_cover_path"
        const val COL_COVER_STATUS = "cover_status"
        const val COL_CACHED_AUDIO_BYTES = "cached_audio_bytes"
        const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
        const val COL_LAST_ACCESSED_AT = "last_accessed_at"
        const val COL_PLAY_COUNT = "play_count"

        const val COL_PLAYLIST_ID = "playlist_id"
        const val COL_PLAYLIST_TITLE = "title"
        const val COL_PLAYLIST_DESCRIPTION = "description"
        const val COL_PLAYLIST_UPDATED_AT = "updated_at"
        const val COL_PLAYLIST_IS_OWN = "is_own"
        const val COL_PLAYLIST_OWNER_NAME = "owner_name"
        const val COL_PLAYLIST_IS_PUBLIC = "is_public"
        const val COL_PLAYLIST_IS_SAVED = "is_saved"
        const val COL_PLAYLIST_KIND = "kind"
        const val COL_PLAYLIST_TRACK_IDS = "track_ids_json"

        const val COL_LIKE_TRACK_ID = "track_id"

        const val STATUS_NONE = "none"
        const val STATUS_READY = "ready"
        const val STATUS_STALE = "stale"
    }
}

data class OfflineCachedMediaEntry(
    val trackId: Long,
    val path: String,
    val sizeBytes: Long,
    val lastAccessedAtMs: Long,
    val playCount: Int
)

data class OfflineStaleMediaPath(
    val trackId: Long,
    val path: String,
    val isAudio: Boolean
)

data class OfflineStorageStats(
    val usedBytes: Long,
    val cachedTrackCount: Int
)

private data class OfflineTrackRecord(
    val audioHash: String?,
    val coverHash: String?
)

private data class ArtistSeed(
    val artistName: String,
    val artistId: Long = -artistName.hashCode().toLong()
) {
    constructor(ref: cy.hexor.furumi.domain.model.ArtistRef) : this(ref.name, ref.id)

    val artistIdOrName: String
        get() = "$artistId:$artistName"
}

private inline fun <T> SQLiteDatabase.transaction(block: SQLiteDatabase.() -> T): T {
    beginTransaction()
    return try {
        val result = block()
        setTransactionSuccessful()
        result
    } finally {
        endTransaction()
    }
}

private fun SQLiteDatabase.insertOrUpdate(
    table: String,
    keyColumn: String,
    key: Long,
    values: ContentValues
) {
    val updated = update(table, values, "$keyColumn = ?", arrayOf(key.toString()))
    if (updated == 0) {
        insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE)
    }
}

private fun List<Long>.toJsonArrayString(): String {
    val array = JSONArray()
    forEach(array::put)
    return array.toString()
}

private fun String?.longListFromJson(): List<Long> {
    if (isNullOrBlank()) return emptyList()
    return runCatching {
        val array = JSONArray(this)
        List(array.length()) { index -> array.optLong(index) }
    }.getOrDefault(emptyList())
}

private fun Boolean.toInt(): Int = if (this) 1 else 0

private fun Cursor.getColumnIndexOrThrowCached(name: String): Int = getColumnIndexOrThrow(name)

private fun Cursor.getString(name: String): String = getString(getColumnIndexOrThrowCached(name))

private fun Cursor.getStringOrNull(name: String): String? {
    val index = getColumnIndexOrThrowCached(name)
    return if (isNull(index)) null else getString(index)
}

private fun Cursor.getLong(name: String): Long = getLong(getColumnIndexOrThrowCached(name))

private fun Cursor.getLongOrNull(name: String): Long? {
    val index = getColumnIndexOrThrowCached(name)
    return if (isNull(index)) null else getLong(index)
}

private fun Cursor.getInt(name: String): Int = getInt(getColumnIndexOrThrowCached(name))

private inline fun <T> Cursor.useCursor(block: (Cursor) -> T): T = use(block)
