/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.browser.awesomebar

import android.content.Context
import android.os.SystemClock
import android.util.AttributeSet
import android.util.LruCache
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.awesomebar.facts.emitProviderQueryTimingFact
import mozilla.components.browser.awesomebar.layout.SuggestionLayout
import mozilla.components.browser.awesomebar.transform.SuggestionTransformer
import mozilla.components.concept.awesomebar.AwesomeBar
import mozilla.components.support.base.utils.NamedThreadFactory
import java.util.concurrent.Executors

private const val PROVIDER_QUERY_THREADS = 3

internal const val PROVIDER_MAX_SUGGESTIONS = 20
internal const val INITIAL_NUMBER_OF_PROVIDERS = 5

/**
 * A customizable [AwesomeBar] implementation.
 */
@Suppress("TooManyFunctions", "LargeClass")
class BrowserAwesomeBar @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr), AwesomeBar {
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    internal var jobDispatcher = Executors.newFixedThreadPool(
        PROVIDER_QUERY_THREADS,
        NamedThreadFactory("BrowserAwesomeBar")
    ).asCoroutineDispatcher()
    private val providers: MutableList<AwesomeBar.SuggestionProvider> = mutableListOf()
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    internal val uniqueSuggestionIds = LruCache<String, Long>(INITIAL_NUMBER_OF_PROVIDERS * PROVIDER_MAX_SUGGESTIONS)
    private var lastUsedSuggestionId = 0L
    internal var suggestionsAdapter = SuggestionsAdapter(this)
    internal var scope = CoroutineScope(Dispatchers.Main)
    internal var job: Job? = null
    internal var listener: (() -> Unit)? = null
    internal var editSuggestionListener: ((String) -> Unit)? = null
    internal val styling: BrowserAwesomeBarStyling
    var customizeForBottomToolbar: Boolean = false

    /**
     * The [SuggestionLayout] implementation controls layout inflation and view binding for suggestions.
     */
    var layout: SuggestionLayout
        get() = suggestionsAdapter.layout
        set(value) { suggestionsAdapter.layout = value }

    /**
     * An optional [SuggestionTransformer] that receives [AwesomeBar.Suggestion] objects from a
     * [AwesomeBar.SuggestionProvider] and returns a new list of transformed [AwesomeBar.Suggestion] objects.
     */
    var transformer: SuggestionTransformer? = null

    init {
        layoutManager = LinearLayoutManager(context, VERTICAL, false)
        adapter = suggestionsAdapter

        context.obtainStyledAttributes(attrs, R.styleable.BrowserAwesomeBar, defStyleAttr, 0).apply {
            styling = BrowserAwesomeBarStyling(
                getColor(
                    R.styleable.BrowserAwesomeBar_awesomeBarTitleTextColor,
                    ContextCompat.getColor(context, R.color.mozac_browser_awesomebar_default_title_text_color)
                ),
                getColor(
                    R.styleable.BrowserAwesomeBar_awesomeBarDescriptionTextColor,
                    ContextCompat.getColor(context, R.color.mozac_browser_awesomebar_default_description_text_color)
                ),
                getColor(
                    R.styleable.BrowserAwesomeBar_awesomeBarChipTextColor,
                    ContextCompat.getColor(context, R.color.mozac_browser_awesomebar_default_chip_text_color)
                ),
                getColor(
                    R.styleable.BrowserAwesomeBar_awesomeBarChipBackgroundColor,
                    ContextCompat.getColor(context, R.color.mozac_browser_awesomebar_default_chip_background_color)
                ),
                getDimensionPixelSize(
                    R.styleable.BrowserAwesomeBar_awesomeBarChipSpacing,
                    resources.getDimensionPixelSize(R.dimen.mozac_browser_awesomebar_default_chip_spacing)
                )
            )
            recycle()
        }
    }

    @Synchronized
    override fun addProviders(vararg providers: AwesomeBar.SuggestionProvider) {
        providers.forEach { provider ->
            val existingProvider = this.providers.find { it.id == provider.id }
            existingProvider?.let {
                throw IllegalStateException("Failed to add provider " +
                        "${provider.id} of type ${provider::class.java.name}. " +
                        "Provider with the same ID already exists: ${it::class.java.name}")
            }
            this.providers.add(provider)
        }

        this.resizeUniqueSuggestionIdCache(this.providers.size)
    }

    @Synchronized
    override fun removeProviders(vararg providers: AwesomeBar.SuggestionProvider) {
        providers.forEach {
            it.onInputCancelled()
            suggestionsAdapter.removeSuggestions(it)
            this.providers.remove(it)
        }
        this.resizeUniqueSuggestionIdCache(this.providers.size)
    }

    @Synchronized
    override fun removeAllProviders() {
        val providerIterator = providers.iterator()

        while (providerIterator.hasNext()) {
            val provider = providerIterator.next()
            provider.onInputCancelled()
            suggestionsAdapter.removeSuggestions(provider)
            providerIterator.remove()
        }
        this.resizeUniqueSuggestionIdCache(0)
    }

    override fun containsProvider(provider: AwesomeBar.SuggestionProvider): Boolean {
        return this.providers.find { it.id == provider.id } != null
    }

    @Synchronized
    override fun onInputStarted() {
        queryProvidersForSuggestions { onInputStarted() }
    }

    @Synchronized
    override fun onInputChanged(text: String) {
        queryProvidersForSuggestions { onInputChanged(text) }
    }

    private fun queryProvidersForSuggestions(
        block: suspend AwesomeBar.SuggestionProvider.() -> List<AwesomeBar.Suggestion>
    ) {
        job?.cancel()

        job = scope.launch {
            suggestionsAdapter.optionallyClearSuggestions()

            providers.forEach { provider ->
                launch {
                    // At this point, we have a timing value for a provider.
                    // We have a choice here - we can try grouping different timings together for a
                    // single user input value, or we can just treat them as entirely independent values.
                    // These timings are correlated with each other in a sense that they act on the same
                    // inputs, and are executed at the same time. However, our goal here is to track performance
                    // of the providers. Each provider acts independently from another; recording their performance
                    // at an individual level will allow us to track that performance over time.
                    // Tracked value will be reflected both in perceived user experience (how quickly results from
                    // a provider show up), and in a purely technical interpretation of how quickly providers
                    // fulfill requests.
                    // Grouping also poses timing challenges - as user is typing, we're trying to cancel these
                    // provider requests. Given that each request can take an arbitrary amount of time to execute,
                    // grouping correctly becomes tricky and we run a risk of omitting certain values - or, of
                    // adding a bunch of complexity just for the sake of "correct grouping".
                    val start = SystemClock.elapsedRealtimeNanos()
                    val suggestions = withContext(jobDispatcher) { provider.block() }
                    val end = SystemClock.elapsedRealtimeNanos()
                    emitProviderQueryTimingFact(provider, timingNs = end - start)

                    val processedSuggestions = processProviderSuggestions(suggestions)
                    suggestionsAdapter.addSuggestions(
                        provider,
                        transformer?.transform(provider, processedSuggestions) ?: processedSuggestions
                    )
                }
            }
        }
    }

    internal fun processProviderSuggestions(suggestions: List<AwesomeBar.Suggestion>): List<AwesomeBar.Suggestion> {
        // We're generating unique suggestion IDs to guard against collisions
        // across providers, but we still require unique IDs for suggestions
        // from individual providers.
        val idSet = mutableSetOf<String>()
        suggestions.forEach {
            check(idSet.add(it.id)) { "${it.provider::class.java.simpleName} returned duplicate suggestion IDs" }
        }

        return suggestions.sortedByDescending { it.score }.take(PROVIDER_MAX_SUGGESTIONS)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()

        job?.cancel()
        jobDispatcher.close()
    }

    @Synchronized
    override fun onInputCancelled() {
        job?.cancel()

        providers.forEach { provider -> provider.onInputCancelled() }
    }

    override fun setOnStopListener(listener: () -> Unit) {
        this.listener = listener
    }

    override fun setOnEditSuggestionListener(listener: (String) -> Unit) {
        this.editSuggestionListener = listener
    }

    /**
     * Returns a unique suggestion ID to make sure ID's can't collide
     * across providers.
     *
     * @param suggestion the suggestion for which a unique ID should be
     * generated.
     *
     * @return the unique ID.
     */
    @Synchronized
    fun getUniqueSuggestionId(suggestion: AwesomeBar.Suggestion): Long {
        val key = suggestion.provider.id + "/" + suggestion.id
        return uniqueSuggestionIds[key] ?: run {
            lastUsedSuggestionId += 1
            uniqueSuggestionIds.put(key, lastUsedSuggestionId)
            lastUsedSuggestionId
        }
    }

    private fun resizeUniqueSuggestionIdCache(providerCount: Int) {
        if (providerCount > 0) {
            this.uniqueSuggestionIds.resize((providerCount * 2) * PROVIDER_MAX_SUGGESTIONS)
        } else {
            this.uniqueSuggestionIds.evictAll()
        }
    }
}

internal data class BrowserAwesomeBarStyling(
    val titleTextColor: Int,
    val descriptionTextColor: Int,
    val chipTextColor: Int,
    val chipBackgroundColor: Int,
    val chipSpacing: Int
)
