package com.yorvana.testsupport

import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetailsResponseListener
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assume
import kotlin.coroutines.resume

private const val PRODUCT_ID = "premium_lifetime"
private const val BILLING_READY_TIMEOUT_MS = 30_000L
private const val BILLING_RETURN_TIMEOUT_MS = 5_000L
private const val MAX_VISIBLE_TEXT_NODES = 80

object BillingSandboxEnvironment {
    private val instrumentation = InstrumentationRegistry.getInstrumentation()
    private val arguments = InstrumentationRegistry.getArguments()
    private val context: Context = instrumentation.targetContext.applicationContext
    private val device: UiDevice = UiDevice.getInstance(instrumentation)

    val scenario: String
        get() = arguments.getString("billingSandboxScenario").orEmpty()

    val account: String
        get() = arguments.getString("billingSandboxAccount").orEmpty()

    fun assumeScenario(expected: String) {
        Assume.assumeTrue(
            "billing sandbox scenario '$expected' not selected",
            scenario == expected,
        )
    }

    fun assumeReady() {
        Assume.assumeTrue("BILLING_SANDBOX_ACCOUNT is missing", account.isNotBlank())
        Assume.assumeTrue(
            "upload-key signing material was not marked ready by the workflow",
            arguments.getString("billingSandboxSigningReady") == "true",
        )
        Assume.assumeTrue(
            "external Play-enabled billing device was not marked ready by the workflow",
            arguments.getString("billingSandboxExternalDeviceReady") == "true",
        )
        Assume.assumeTrue(
            "license-tester account '$account' is not visible on the device",
            isAccountSignedIn(account),
        )
        Assume.assumeTrue(
            "BillingClient.queryProductDetailsAsync($PRODUCT_ID) returned no product details",
            runBlocking { hasProductDetails() },
        )
    }

    fun completePurchaseFlow() {
        val billingActionTexts =
            listOf(
                "1-tap buy",
                "Buy",
                "Purchase",
                "Continue",
                "Agree",
                "No, thanks",
                "OK",
                "Got it",
            )
        repeat(6) {
            if (waitForAppForeground(BILLING_RETURN_TIMEOUT_MS)) {
                return
            }

            val billingAction =
                waitForBillingAction(billingActionTexts)
                    ?: throw AssertionError(
                        "Play Billing purchase UI did not expose a supported action. " +
                            "Visible text: ${visibleTextSnapshot()}",
                    )
            billingAction.click()
        }

        throw AssertionError(
            "Play Billing purchase UI did not return to ${context.packageName}. " +
                "Visible text: ${visibleTextSnapshot()}",
        )
    }

    fun purchaseSnapshot(): String = runBlocking { queryPurchaseSnapshot() }

    private fun isAccountSignedIn(account: String): Boolean {
        val output = runCatching { device.executeShellCommand("dumpsys account") }.getOrDefault("")
        return output.contains(account, ignoreCase = true)
    }

    private fun waitForAppForeground(timeoutMillis: Long): Boolean {
        val deadline = System.currentTimeMillis() + timeoutMillis
        while (System.currentTimeMillis() < deadline) {
            if (device.currentPackageName == context.packageName) {
                return true
            }
            Thread.sleep(250)
        }
        return false
    }

    private fun waitForBillingAction(texts: List<String>): UiObject2? {
        val deadline = System.currentTimeMillis() + BILLING_READY_TIMEOUT_MS
        while (System.currentTimeMillis() < deadline) {
            val clickableAction =
                device
                    .findObjects(By.clickable(true))
                    .firstOrNull { node ->
                        val candidateText = node.visibleText()
                        texts.any { text -> candidateText.contains(text, ignoreCase = true) }
                    }
            if (clickableAction != null) return clickableAction

            Thread.sleep(500)
        }
        return null
    }

    private fun UiObject2.visibleText(): String {
        val texts = mutableListOf<String>()
        val nodes = ArrayDeque<UiObject2>()
        nodes.add(this)

        var visitedNodes = 0
        while (nodes.isNotEmpty() && visitedNodes < MAX_VISIBLE_TEXT_NODES) {
            val node = nodes.removeFirst()
            visitedNodes++

            node
                .text
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let(texts::add)
            node.children.asReversed().forEach(nodes::addFirst)
        }

        return texts.joinToString(" ")
    }

    private fun visibleTextSnapshot(): String {
        val nodes = device.findObjects(By.clickable(true)) + device.findObjects(By.textStartsWith(""))
        val texts =
            nodes
                .mapNotNull { it.text?.trim() }
                .filter { it.isNotBlank() }
                .distinct()
                .take(20)
        return texts.ifEmpty { listOf("<none>") }.joinToString(" | ")
    }

    private suspend fun hasProductDetails(): Boolean {
        val client =
            BillingClient
                .newBuilder(context)
                .setListener { _, _ -> }
                .enablePendingPurchases(
                    PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
                ).build()
        return try {
            val setupResult = connect(client)
            if (setupResult.responseCode != BillingClient.BillingResponseCode.OK) {
                return false
            }
            queryProductDetails(client)
        } finally {
            client.endConnection()
        }
    }

    private suspend fun connect(client: BillingClient): BillingResult =
        suspendCancellableCoroutine { continuation ->
            client.startConnection(
                object : BillingClientStateListener {
                    override fun onBillingSetupFinished(billingResult: BillingResult) {
                        if (continuation.isActive) continuation.resume(billingResult)
                    }

                    override fun onBillingServiceDisconnected() = Unit
                },
            )
        }

    private suspend fun queryProductDetails(client: BillingClient): Boolean =
        suspendCancellableCoroutine { continuation ->
            val params =
                QueryProductDetailsParams
                    .newBuilder()
                    .setProductList(
                        listOf(
                            QueryProductDetailsParams.Product
                                .newBuilder()
                                .setProductId(PRODUCT_ID)
                                .setProductType(BillingClient.ProductType.INAPP)
                                .build(),
                        ),
                    ).build()

            client.queryProductDetailsAsync(
                params,
                ProductDetailsResponseListener { billingResult, details ->
                    val hasDetails =
                        billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
                            details.productDetailsList.isNotEmpty()
                    if (continuation.isActive) continuation.resume(hasDetails)
                },
            )
        }

    private suspend fun queryPurchaseSnapshot(): String {
        val client =
            BillingClient
                .newBuilder(context)
                .setListener { _, _ -> }
                .enablePendingPurchases(
                    PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
                ).build()
        return try {
            val setupResult = connect(client)
            if (setupResult.responseCode != BillingClient.BillingResponseCode.OK) {
                return "setup responseCode=${setupResult.responseCode}, debugMessage=${setupResult.debugMessage}"
            }

            val params =
                QueryPurchasesParams
                    .newBuilder()
                    .setProductType(BillingClient.ProductType.INAPP)
                    .build()
            val (billingResult, purchases) = queryPurchases(client, params)
            val matchingPurchases = purchases.filter { it.products.contains(PRODUCT_ID) }
            val details =
                matchingPurchases.joinToString(prefix = "[", postfix = "]") { purchase ->
                    "products=${purchase.products}, state=${purchase.purchaseStateName()}, " +
                        "acknowledged=${purchase.isAcknowledged}"
                }
            "query responseCode=${billingResult.responseCode}, debugMessage=${billingResult.debugMessage}, " +
                "purchaseCount=${purchases.size}, premiumPurchaseCount=${matchingPurchases.size}, " +
                "premiumPurchases=$details"
        } finally {
            client.endConnection()
        }
    }

    private suspend fun queryPurchases(
        client: BillingClient,
        params: QueryPurchasesParams,
    ): Pair<BillingResult, List<Purchase>> =
        suspendCancellableCoroutine { continuation ->
            client.queryPurchasesAsync(
                params,
                PurchasesResponseListener { billingResult, purchases ->
                    if (continuation.isActive) continuation.resume(billingResult to purchases)
                },
            )
        }

    private fun Purchase.purchaseStateName(): String =
        when (purchaseState) {
            Purchase.PurchaseState.PURCHASED -> "PURCHASED"
            Purchase.PurchaseState.PENDING -> "PENDING"
            Purchase.PurchaseState.UNSPECIFIED_STATE -> "UNSPECIFIED_STATE"
            else -> purchaseState.toString()
        }
}
