package theorycrafter.storage

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import theorycrafter.utils.StoredCollection
import java.io.DataInput
import java.io.DataOutput
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max


/**
 * The repository of fitting related data.
 */
class FittingRepository private constructor(


    /**
     * The stored collection of repository items.
     */
    private val storedCollection: StoredCollection<FitRepoMetadata, FitRepoItem>,


) {


    /**
     * Whether this repository has been closed.
     */
    @Volatile
    private var closed = false


    /**
     * A channel to pass commands to the I/O coroutine.
     */
    private val ioCommands = Channel<IoCommand>(capacity = 20)


    /**
     * The stored fits, mapped by their id.
     */
    private val fitById: MutableMap<Int, StoredFit> = storedCollection.items()
        .mapNotNull { (it as? FitRepoItem.Fit)?.storedFit }
        .sortedBy { it.id!! }  // Sort so that `fits` is in the correct order (mutableMapTo preserves insertion order)
        .associateByTo(mutableMapOf()) { it.id!! }


    /**
     * The collection of stored fits.
     */
    val fits: Collection<StoredFit>
        get() = fitById.values


    /**
     * The highest known [StoredFit] id.
     *
     * Whenever a new fit is created, we increment this and use the new value for the id of the new fit.
     */
    private val highestFitId = AtomicInteger(storedCollection.userMetadata.maxFitId)


    /**
     * The stored skill sets, mapped by their id.
     */
    private val skillSetById: MutableMap<Int, StoredSkillSet> = storedCollection.items()
        .mapNotNull { (it as? FitRepoItem.SkillSet)?.storedSkillSet }
        .sortedBy { it.id }  // Sort so that skillSets presents it in the correct order (mutableMapTo preserves insertion order)
        .associateByTo(mutableMapOf()) { it.id!! }


    /**
     * The collection of stored skill sets.
     */
    val skillSets: Collection<StoredSkillSet>
        get() = skillSetById.values


    /**
     * The highest known [StoredSkillSet] id.
     *
     * Whenever a new skill set is created, we increment this and use the new value for the id of the new skill set.
     */
    private val highestSkillSetId = AtomicInteger(storedCollection.userMetadata.maxSkillSetId)


    init {
        CoroutineScope(Dispatchers.IO).launch {
            for (command in ioCommands) {
                when (command) {
                    is IoCommand.WriteNewFit -> {
                        val fitId = command.storedFit.id ?: error("Can't write fit with no id")
                        val metadata = storedCollection.userMetadata
                        if (fitId > metadata.maxFitId)
                            storedCollection.setUserMetadata(metadata.copy(maxFitId = fitId))
                        storedCollection.add(command.storedFit.asRepoItem())
                    }
                    is IoCommand.DeleteFits -> storedCollection.removeMatching {
                        (it is FitRepoItem.Fit) && (it.storedFit.id in command.fitIds)
                    }
                    is IoCommand.ReplaceFit -> {
                        storedCollection.remove(command.replacedFit.asRepoItem())
                        storedCollection.add(command.newFit.asRepoItem())
                    }
                    is IoCommand.WriteNewSkillSet -> {
                        val skillSetId = command.storedSkillSet.id ?: error("Can't write skill set with no id")
                        val metadata = storedCollection.userMetadata
                        if (skillSetId > metadata.maxSkillSetId)
                            storedCollection.setUserMetadata(metadata.copy(maxSkillSetId = skillSetId))
                        storedCollection.add(command.storedSkillSet.asRepoItem())
                    }
                    is IoCommand.DeleteSkillSets -> storedCollection.removeMatching {
                        (it is FitRepoItem.SkillSet) && (it.storedSkillSet.id in command.skillSetIds)
                    }
                    is IoCommand.ReplaceSkillSet -> {
                        storedCollection.remove(command.replacedSkillSet.asRepoItem())
                        storedCollection.add(command.newSkillSet.asRepoItem())
                    }
                }
            }
        }
    }


    /**
     * Throws an [IllegalStateException] if this repository is closed.
     */
    private fun checkNotClosed() {
        check(!closed) { "FitRepository (${System.identityHashCode(this)}) is closed" }
    }


    /**
     * Closes this fit repository.
     */
    fun close() {
        closed = true
        ioCommands.close()
    }


    /**
     * Assigns an id to the given fit and returns it.
     */
    fun assignFitId(fit: StoredFit): Int {
        if (fit.id != null)
            throw IllegalArgumentException("Fit already has an id: ${fit.id}")

        val id = highestFitId.incrementAndGet()
        fit.id = id
        return id
    }


    /**
     * Adds a new fit to the repo, assigning it an id, and returns the id.
     */
    suspend fun addFit(storedFit: StoredFit): Int {
        checkNotClosed()

        val id = storedFit.id ?: assignFitId(storedFit)
        fitById[id] = storedFit

        ioCommands.send(IoCommand.WriteNewFit(storedFit))

        return id
    }


    /**
     * Rewrites the fit of the given handle with a new one.
     */
    suspend fun updateFit(current: StoredFit, updated: StoredFit) {
        val id = current.id ?: error("Specified fit does not have an id")
        assert(updated.id == id)

        fitById[id] = updated
        ioCommands.send(IoCommand.ReplaceFit(current, updated))
    }


    /**
     * Deletes the given fits.
     */
    suspend fun deleteFits(fits: Collection<StoredFit>) {
        checkNotClosed()

        val deletedFitIds = fits.mapNotNullTo(mutableSetOf()) { it.id }
        fitById.keys.removeAll(deletedFitIds)
        ioCommands.send(IoCommand.DeleteFits(deletedFitIds))
    }


    /**
     * Adds a new skill set to the repo, assigning it an id, and returns the id.
     */
    suspend fun addSkillSet(storedSkillSet: StoredSkillSet): Int {
        checkNotClosed()

        val id = highestSkillSetId.incrementAndGet()
        storedSkillSet.id = id

        ioCommands.send(IoCommand.WriteNewSkillSet(storedSkillSet))

        return id
    }


    /**
     * Rewrites the skill set of the given handle with a new one.
     */
    suspend fun replaceSkillSet(current: StoredSkillSet, updated: StoredSkillSet) {
        val id = current.id ?: error("Specified skill set does not have an id")
        assert(updated.id == id)

        skillSetById[id] = updated
        ioCommands.send(IoCommand.ReplaceSkillSet(current, updated))
    }


    /**
     * Deletes the given skill sets.
     */
    suspend fun deleteSkillSets(handles: Collection<StoredSkillSet>) {
        checkNotClosed()

        val deletedSkillSetIds = handles.mapNotNullTo(mutableSetOf()) { it.id }
        skillSetById.keys.removeAll(deletedSkillSetIds)
        ioCommands.send(IoCommand.DeleteSkillSets(deletedSkillSetIds))
    }


    companion object {


        /**
         * Creates a [FittingRepository] which stores fits in the given file.
         */
        fun create(file: File): FittingRepository {
            val storedFits = StoredCollection(
                storageFile = file,
                userMetadataSerializer = FitRepoMetadata.Serializer,
                initialUserMetadata = { FitRepoMetadata(maxFitId = 0, maxSkillSetId = 0) },
                userMetadataUpgrader = FitRepoMetadata.Serializer::upgrade,
                itemSerializer = Serializer
            )
            return FittingRepository(storedFits)
        }


    }


    /**
     * Encapsules commands for modifying the fits stored on disk.
     */
    private sealed interface IoCommand {

        data class WriteNewFit(val storedFit: StoredFit): IoCommand

        data class DeleteFits(val fitIds: Collection<Int>): IoCommand

        data class ReplaceFit(val replacedFit: StoredFit, val newFit: StoredFit): IoCommand

        data class WriteNewSkillSet(val storedSkillSet: StoredSkillSet): IoCommand

        data class DeleteSkillSets(val skillSetIds: Collection<Int>): IoCommand

        data class ReplaceSkillSet(val replacedSkillSet: StoredSkillSet, val newSkillSet: StoredSkillSet): IoCommand

    }


}


/**
 * The metadata we store in the fit repo.
 */
private data class FitRepoMetadata(


    /**
     * The highest fit id that has ever been used.
     *
     * Note that it doesn't necessarily still exist, as it could have been deleted.
     */
    val maxFitId: Int,


    /**
     * The highest skill set id that was ever used.
     *
     * Note that it doesn't necessarily still exist, as it could have been deleted.
     */
    val maxSkillSetId: Int


) {


    /**
     * The serializer for [FitRepoMetadata].
     */
    companion object Serializer: StoredCollection.Serializer<FitRepoMetadata> {


        override val itemFormatVersion = 1


        override fun readItem(input: DataInput, formatVersion: Int): FitRepoMetadata {
            val maxFitId = if (formatVersion >= 1) input.readInt() else -1
            val maxSkillSetId = if (formatVersion >= 1) input.readInt() else -1
            return FitRepoMetadata(
                maxFitId = maxFitId,
                maxSkillSetId = maxSkillSetId
            )
        }


        override fun writeItem(output: DataOutput, item: FitRepoMetadata) {
            output.writeInt(item.maxFitId)
            output.writeInt(item.maxSkillSetId)
        }


        /**
         * Returns the [FitRepoMetadata] for the current format version, based on the value at the given version.
         */
        fun upgrade(
            @Suppress("UNUSED_PARAMETER") metadata: FitRepoMetadata,
            version: Int,
            items: Sequence<FitRepoItem>
        ): FitRepoMetadata {
            if (version == 0) {
                val maxFitId = items
                    .mapNotNull { (it as? FitRepoItem.Fit)?.storedFit }
                    .maxOfOrNull {
                        max(
                            it.id!!,
                            it.getDependencyFitIds().maxOrNull() ?: 0
                        )
                    } ?: 0
                val maxSkillSetId = items
                    .mapNotNull { (it as? FitRepoItem.SkillSet)?.storedSkillSet }
                    .maxOfOrNull {
                        it.id!!
                    } ?: 0

                return FitRepoMetadata(maxFitId = maxFitId, maxSkillSetId = maxSkillSetId)
            }
            else {
                throw CorruptFitRepository("Unknown metadata format version: $version")
            }
        }


    }


}


/**
 * The union type for all types of objects we store in the repository.
 */
private sealed interface FitRepoItem {

    /**
     * The wrapper for stored fits.
     */
    data class Fit(val storedFit: StoredFit): FitRepoItem

    /**
     * The wrapper for stored skill sets.
     */
    data class SkillSet(val storedSkillSet: StoredSkillSet): FitRepoItem

}


/**
 * Returns a [FitRepoItem] wrapping the given [StoredFit].
 */
private fun StoredFit.asRepoItem() = FitRepoItem.Fit(this)


/**
 * Returns a [FitRepoItem] wrapping the given [StoredSkillSet].
 */
private fun StoredSkillSet.asRepoItem() = FitRepoItem.SkillSet(this)


/**
 * The format versions of item types stored in the repo.
 */
private class StoredItemFormatVersions(
    val fits: Int,
    val skillSets: Int?
)


/**
 * The serializer of [FitRepoItem]s.
 */
private val Serializer = object: StoredCollection.Serializer<FitRepoItem> {


    /**
     * The type id of [FitRepoItem.Fit].
     */
    private val FIT_TYPE_ID = 1


    /**
     * The type id of [FitRepoItem.SkillSet].
     */
    private val SKILL_SET_TYPE_ID = 2


    /**
     * The mapping of the format versions of this serializer (i.e. [itemFormatVersion]) to the format versions of
     * [StoredFit.Serializer] and [StoredSkillSet.Serializer]. A `null` value means there were no items of this type in
     * that version.
     *
     * Note that prior to version 5 the repo contained only [StoredFit]s, and the [StoredCollection]s serializer was
     * simply [StoredFit.Serializer].
     */
    private val itemFormatVersionsByRepoFormatVersion = mapOf(
        1 to StoredItemFormatVersions(fits = 1, skillSets = null),
        2 to StoredItemFormatVersions(fits = 2, skillSets = null),
        3 to StoredItemFormatVersions(fits = 3, skillSets = null),
        4 to StoredItemFormatVersions(fits = 4, skillSets = null),
        5 to StoredItemFormatVersions(fits = 5, skillSets = 1),
        6 to StoredItemFormatVersions(fits = 6, skillSets = 1),
        7 to StoredItemFormatVersions(fits = 7, skillSets = 1),
        8 to StoredItemFormatVersions(fits = 7, skillSets = 2),
        9 to StoredItemFormatVersions(fits = 8, skillSets = 2),
        10 to StoredItemFormatVersions(fits = 9, skillSets = 2),
        11 to StoredItemFormatVersions(fits = 10, skillSets = 2),
        12 to StoredItemFormatVersions(fits = 11, skillSets = 2),
        13 to StoredItemFormatVersions(fits = 12, skillSets = 2),
        14 to StoredItemFormatVersions(fits = 13, skillSets = 2),  // adaptation cycles
    )


    override val itemFormatVersion = itemFormatVersionsByRepoFormatVersion.keys.max()


    override fun readItem(input: DataInput, formatVersion: Int): FitRepoItem {
        val formatVersions = itemFormatVersionsByRepoFormatVersion[formatVersion] ?:
            throw CorruptFitRepository("Bad format version: $formatVersion")

        val skillSetFormatVersion = formatVersions.skillSets
        if (skillSetFormatVersion == null) {
            // Before there were skill sets there was no typeId; the repo was serialized with StoredFit.Serializer
            return FitRepoItem.Fit(StoredFit.Serializer.readItem(input, formatVersions.fits))
        }

        return when (val typeId = input.readInt()) {
            FIT_TYPE_ID -> StoredFit.Serializer.readItem(input, formatVersions.fits).asRepoItem()
            SKILL_SET_TYPE_ID -> StoredSkillSet.Serializer.readItem(input, skillSetFormatVersion).asRepoItem()
            else -> throw CorruptFitRepository("Bad item type id: $typeId")
        }
    }


    override fun writeItem(output: DataOutput, item: FitRepoItem) {
        when (item) {
            is FitRepoItem.Fit -> {
                output.writeInt(FIT_TYPE_ID)
                StoredFit.Serializer.writeItem(output, item.storedFit)
            }
            is FitRepoItem.SkillSet -> {
                output.writeInt(SKILL_SET_TYPE_ID)
                StoredSkillSet.Serializer.writeItem(output, item.storedSkillSet)
            }
        }
    }


}


/**
 * The exception thrown when the repository data is unexpected in some way.
 */
class CorruptFitRepository(message: String): IllegalArgumentException(message)
