package dev.privacyllc.period

import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

/**
 * The application scope must survive a throw, because nobody is watching it.
 *
 * `PeriodApplication.onCreate` runs in **every** process, including the ones
 * WorkManager starts after a reboot and at the daily reminder — no Activity, no
 * screen, nothing to show an error. An uncaught throw there ends the process,
 * and the only symptom a user gets is reminders that stopped.
 *
 * `SupervisorJob` is what makes this easy to get wrong: it stops a failing
 * child cancelling its siblings, which reads like containment and is not. The
 * exception still goes to the [kotlinx.coroutines.CoroutineExceptionHandler] in
 * the context, and with none present it reaches the thread's default handler.
 *
 * These tests assert the behaviour rather than the shape. Checking that a
 * handler is present in the context would pass over a handler that rethrows.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ApplicationScopeTest {

    /**
     * Installs a recording default handler for the duration, and restores the
     * real one afterwards — an escaped exception in a test runner is otherwise
     * charged to whichever test happens to be running next.
     */
    private fun <T> recordingUncaughtExceptions(block: (AtomicReference<Throwable?>) -> T): T {
        val original = Thread.getDefaultUncaughtExceptionHandler()
        val seen = AtomicReference<Throwable?>(null)
        Thread.setDefaultUncaughtExceptionHandler { _, e -> seen.compareAndSet(null, e) }
        return try {
            block(seen)
        } finally {
            Thread.setDefaultUncaughtExceptionHandler(original)
        }
    }

    /** Long enough for a scope *without* a handler to have recorded the crash. */
    private fun awaitCrashOrGiveUp(seen: AtomicReference<Throwable?>) {
        val deadline = System.currentTimeMillis() + 500
        while (seen.get() == null && System.currentTimeMillis() < deadline) Thread.sleep(10)
    }

    @Test
    fun `a throwing coroutine does not reach the default uncaught handler`() =
        recordingUncaughtExceptions { seen ->
            val scope = PeriodApplication.applicationScope()

            // The realistic failure: the forecast flow runs the prediction
            // engine, and Prediction's init block enforces its window
            // invariants with require().
            val job = scope.launch { throw IllegalArgumentException("a prediction invariant failed") }
            runBlocking { job.join() }
            awaitCrashOrGiveUp(seen)

            assertNull(
                "a throw on the application scope reached the default handler, " +
                    "which ends the process — in the background, with no UI",
                seen.get(),
            )
            assertTrue("the scope must outlive a failed child", scope.isActive)
        }

    @Test
    fun `the scope still accepts work after a child has failed`() =
        recordingUncaughtExceptions {
            val scope = PeriodApplication.applicationScope()
            val ranAfterwards = AtomicBoolean(false)

            runBlocking { scope.launch { throw IllegalStateException("the database could not be opened") }.join() }
            runBlocking { scope.launch { ranAfterwards.set(true) }.join() }

            // The two reminder collections are siblings on this scope. One
            // failing must not take the other with it.
            assertTrue("a sibling collection must survive", ranAfterwards.get())
        }
}
