package theorycrafter

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eve.data.EveData
import kotlinx.coroutines.*
import theorycrafter.fits.FitHandle
import theorycrafter.fits.FitsContext
import theorycrafter.fitting.Fit
import theorycrafter.fitting.FittingEngine
import theorycrafter.storage.FittingRepository
import theorycrafter.tournaments.TournamentContext
import theorycrafter.ui.EveAutoSuggest
import theorycrafter.ui.fiteditor.FitEditorFitSavedState
import theorycrafter.utils.*
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.DateFormat
import javax.swing.JOptionPane
import kotlin.system.exitProcess


/**
 * Holds all the context objects of the app.
 */
object TheorycrafterContext {


    /**
     * The actual data [TheorycrafterContext] consists of.
     */
    private class Fields(
        val eveData: EveData,
        val fittingEngine: FittingEngine,
        val fittingRepository: FittingRepository,
        val autoSuggest: EveAutoSuggest,
        val fitSearch: FitSearch,
        val fitsContext: FitsContext,
        val skillContext: SkillSetsContext,
        val tournamentContext: TournamentContext,
    ) {

        val fitEditorSavedFitStateByFitHandle = mutableMapOf<FitHandle, FitEditorFitSavedState>()

    }


    /**
     * The loaded data used by the [TheorycrafterContext], set by [initialize].
     */
    private var _fields: Fields? = null


    /**
     * A getter for [_fields] that asserts it being non-null.
     */
    private val fields: Fields
        get() = _fields ?: error("TheorycrafterContext has not been initialized")


    /**
     * The application settings.
     */
    lateinit var settings: TheorycrafterSettings


    /**
     * The [EveData] we're fitting with.
     */
    val eveData: EveData
        get() = fields.eveData


    /**
     * The fitting engine.
     */
    @Suppress("unused")
    private val fittingEngine: FittingEngine
        get() = fields.fittingEngine


    /**
     * The fittings repository.
     */
    private val fittingRepository: FittingRepository
        get() = fields.fittingRepository


    /**
     * The object through which fits can be managed.
     */
    val fits: FitsContext
        get() = fields.fitsContext


    /**
     * The object through which skill sets can be managed.
     */
    val skillSets: SkillSetsContext
        get() = fields.skillContext


    /**
     * The object through which tournaments can be obtained.
     */
    val tournaments: TournamentContext
        get() = fields.tournamentContext


    /**
     * Auto-suggest functions.
     */
    val autoSuggest: EveAutoSuggest
        get() = fields.autoSuggest


    /**
     * Allows searching for [FitHandle]s by a text query.
     */
    private val fitSearch: FitSearch
        get() = fields.fitSearch


    /**
     * The state we preserve for the fit editor for each fit, between edit sessions.
     */
    private val fitEditorSavedFitStateByFitHandle: MutableMap<FitHandle, FitEditorFitSavedState>
        get() = fields.fitEditorSavedFitStateByFitHandle


    /**
     * The eve item prices, loaded asynchronously.
     */
    var eveItemPrices: EveItemPrices? by mutableStateOf(null)


    /**
     * The listener to changes in fits.
     */
    private val fitsChangeListener = object: FitsContext.ChangeListener {

        override fun onFitAdded(fitHandle: FitHandle) {
            fitSearch.add(fitHandle)
        }

        override fun onFitNameChanged(fitHandle: FitHandle) {
            fitSearch.onNameChanged(fitHandle)
        }

        override fun onFitTagsChanged(fitHandle: FitHandle) {
            fitSearch.onTagsChanged(fitHandle)
        }

        override suspend fun onFitsDeleted(
            deletedFitHandles: Collection<FitHandle>,
            affectedFits: Collection<Fit>
        ) {
            for (fitHandle in deletedFitHandles) {
                fitSearch.remove(fitHandle)
                fitEditorSavedFitStateByFitHandle.remove(fitHandle)
            }

            for (fit in affectedFits) {
                fitEditorSavedFitStateByFitHandle[fits.handleOf(fit)]?.onFitEditedExternally()
            }

            tournaments.onFitsDeleted(deletedFitHandles)
        }
    }


    /**
     * Initializes the fitting context instance.
     * This must be called exactly once, before any other functions.
     */
    suspend fun initialize(
        fitsFile: File = File(USER_FILES_DIR, "fits.dat"),
        tournamentsDirectory: File = File(USER_FILES_DIR, "tournaments"),
        eveDataDeferred: Deferred<EveData>,
        isRunningTest: Boolean = false,
        onProgress: (String) -> Unit,
    ) {
        if (!eveDataDeferred.isCompleted)
            onProgress("Loading EVE data")
        val eveData = eveDataDeferred.await()

        // This is computed lazily, and we'd like to start computing it
        @OptIn(DelicateCoroutinesApi::class)
        GlobalScope.launch(Dispatchers.Default) {
            eveData.itemTypesByMarketGroup
        }

        onProgress("Creating fitting engine")
        val fittingEngine = timeAction("Creating fitting engine") {
            FittingEngine(
                eveData = eveData,
                defaultSkillLevels = BuiltInSkillSets.Default::levelOfSkill,
                fittingRestrictionsEnforcementMode = FittingEngine.FittingRestrictionsEnforcementMode.SET_ITEM_LEGALITY
            )
        }

        onProgress("Loading stored fits")
        val fittingRepository = timeAction("Loading stored fits") {
            withContext(Dispatchers.IO) {
                loadFittingRepository(fitsFile, isRunningTest)
            }
        }

        val fitsContext = FitsContext(
            repo = fittingRepository,
            fittingEngine = fittingEngine,
            changeListener = fitsChangeListener
        )

        val skillContext = SkillSetsContext(
            repo = fittingRepository,
            fittingEngine = fittingEngine,
        )

        onProgress("Indexing stored fits")
        val fitSearch = timeAction("Creating fit search") {
            FitSearch(eveData).also {
                for (fitHandle in fitsContext.handles)
                    it.add(fitHandle)
            }
        }

        if (!tournamentsDirectory.isDirectory && !tournamentsDirectory.mkdirs())
            throw IOException("Unable to create directory $tournamentsDirectory")
        val tournamentContext = TournamentContext(eveData, tournamentsDirectory)

        _fields = Fields(
            eveData = eveData,
            fittingEngine = fittingEngine,
            fittingRepository = fittingRepository,
            autoSuggest = EveAutoSuggest(eveData),
            fitSearch = fitSearch,
            fitsContext = fitsContext,
            skillContext = skillContext,
            tournamentContext = tournamentContext
        )

        skillSets.setDefaultSkillSet(skillSets.handleByIdOrNull(settings.defaultSkillSetId))
    }


    /**
     * Loads the [FittingRepository] from the given file.
     */
    private fun loadFittingRepository(file: File, isRunningTest: Boolean = false): FittingRepository {
        if (isRunningTest)
            return FittingRepository.create(file)

        val repo = runCatchingExceptCancellation { FittingRepository.create(file) }.getOrNull()
        val backupFile = File(file.parentFile, file.name + ".backup")

        if (repo != null) {
            // Create a backup of the file after successful read
            Files.copy(file.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
            return repo
        }

        // Preserve the corrupt file
        val corruptFile = File(file.parentFile, file.name + ".corrupt").toPath()
        Files.copy(file.toPath(), corruptFile, StandardCopyOption.REPLACE_EXISTING)

        // Check if a backup exists
        if (backupFile.exists()) {
            // Ask the user whether to load the backup or start from scratch
            val backupTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM).format(file.lastModified())
            val response = JOptionPane.showOptionDialog(
                null,
                """
                    The fits data file seems to be corrupt, but luckily you have a backup from $backupTime.
                    Would you like to load the backup or start anew?
                       
                    (The corrupt file has been preserved; please send it to ${Theorycrafter.MaintainerEmail} for analysis)
                """.trimIndent(),
                "Corrupt Fits File",
                JOptionPane.YES_NO_OPTION,
                JOptionPane.WARNING_MESSAGE,
                null,
                arrayOf("Load fits from backup", "Start from scratch"),
                "Load fits from backup"
            )

            when (response) {
                JOptionPane.YES_OPTION -> {
                    Files.copy(backupFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
                    return loadFittingRepository(file)
                }
                JOptionPane.NO_OPTION -> {
                    file.delete()
                    return loadFittingRepository(file)
                }
                else -> exitProcess(1)
            }
        } else {
            val response = JOptionPane.showOptionDialog(
                null,
                """
                    The fits data file seems to be corrupt and there is no backup file.
                    We'll have to start from scratch.   
                """.trimIndent(),
                "Corrupt Fits File",
                JOptionPane.OK_CANCEL_OPTION,
                JOptionPane.WARNING_MESSAGE,
                null,
                arrayOf("Start from scratch", "Quit ${Theorycrafter.AppName}"),
                "Start from scratch"
            )
            if (response == JOptionPane.OK_OPTION) {
                file.delete()
                return loadFittingRepository(file)
            } else {
                exitProcess(1)
            }
        }
    }


    /**
     * Deinitializes this context.
     */
    fun close() {
        if (_fields != null) {
            fittingRepository.close()
            _fields = null
        }
    }


    /**
     * Returns the [FitHandle]s matching the given text; `null` if the text is empty.
     */
    suspend fun queryFits(text: String): List<FitHandle>? {
        fits.handlesKey
        return fitSearch.query(text)
    }


    /**
    * Returns the existing [FitEditorFitSavedState] for the given fit, or creates and returns a new one.
     */
    fun fitEditorSavedFitStateFor(fit: Fit): FitEditorFitSavedState {
        return synchronized(fitEditorSavedFitStateByFitHandle) {
            fitEditorSavedFitStateByFitHandle.getOrPut(fits.handleOf(fit), ::FitEditorFitSavedState)
        }
    }


    /**
     * Returns the auto-suggest for fits.
     */
    val fitsAutoSuggest: AutoSuggest<FitHandle> = AutoSuggest { text -> queryFits(text) }


}


/**
 * Implements searching [FitHandle]s by text.
 */
private class FitSearch(val eveData: EveData) {


    /**
     * The [StringSearch] backing this [FitSearch].
     */
    private val search = StringSearch(
        minCharacters = 1,  // FitSelector relies on this being 1
        suggestionComparator = compareBy(FitHandle::name),
        isTermDelimiter = " -()[]{},".containsFunction()
    )


    /**
     * The configuration for ship name search.
     */
    private val shipNameSearchConfig = SearchConfig(includeAcronyms = true)


    /**
     * Maps the [FitHandle]s in [search] to their corresponding [AddedItems], so we can remove the fit handles when
     * asked.
     */
    private val fitHandleAddedItems = mutableMapOf<FitHandle, AddedItems>()


    /**
     * Adds the [FitHandle]s fit name to the search.
     */
    private fun addFitName(fitHandle: FitHandle): StringSearch.AddedItem {
        return search.addItem(fitHandle, fitHandle.name)
    }


    /**
     * Adds the [FitHandle]'s ship name to the search.
     */
    private fun addShipName(fitHandle: FitHandle): StringSearch.AddedItem {
        val shipType = eveData.shipType(fitHandle.shipTypeId)
        return search.addItem(fitHandle, shipType.name, shipNameSearchConfig)
    }


    /**
     * Adds the [FitHandle]s tags to the search.
     */
    private fun addTags(fitHandle: FitHandle): List<StringSearch.AddedItem> {
        val tags = fitHandle.requireStoredFit().tags
        return tags.map { search.addItem(fitHandle, "#$it") }
    }


    /**
     * Adds the given [FitHandle] to the search.
     */
    fun add(fitHandle: FitHandle) {
        val fitNameItem = addFitName(fitHandle)
        val shipNameItem = addShipName(fitHandle)
        val tagsItems = addTags(fitHandle)

        fitHandleAddedItems[fitHandle] = AddedItems(
            fitName = fitNameItem,
            shipName = shipNameItem,
            tags = tagsItems
        )
    }


    /**
     * Removes the given [FitHandle] from the search.
     */
    fun remove(fitHandle: FitHandle) {
        val addedItems = fitHandleAddedItems.remove(fitHandle)
            ?: throw IllegalArgumentException("Unknown fit handle: $fitHandle")
        addedItems.fitName.remove()
        addedItems.shipName.remove()
        addedItems.tags.forEach { it.remove() }
    }


    /**
     * Updates the search when a fit's name has changed.
     */
    fun onNameChanged(fitHandle: FitHandle) {
        val addedItems = fitHandleAddedItems[fitHandle]
            ?: throw IllegalArgumentException("Unknown fit handle: $fitHandle")
        addedItems.fitName.remove()
        addedItems.fitName = addFitName(fitHandle)
    }


    /**
     * Updates the search when a fit's tags have changed.
     */
    fun onTagsChanged(fitHandle: FitHandle) {
        val addedItems = fitHandleAddedItems[fitHandle]
            ?: throw IllegalArgumentException("Unknown fit handle: $fitHandle")
        addedItems.tags.forEach { it.remove() }
        addedItems.tags = addTags(fitHandle)
    }


    /**
     * Returns the list of [FitHandle]s matching the given text; `null` if the text is empty.
     */
    suspend fun query(text: String): List<FitHandle>? {
        return search.query(text)
    }


    /**
     * Holds the [AddedItems] for a certain [FitHandle].
     */
    private class AddedItems(
        var fitName: StringSearch.AddedItem,
        val shipName: StringSearch.AddedItem,
        var tags: List<StringSearch.AddedItem>
    )


}
