package com.ponzischeme89.memby.benchmark

import android.view.KeyEvent
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.TraceSectionMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
 * How long a viewer waits between pressing Play and seeing a picture.
 *
 * This exists because the question could not be settled by hand. Timing a few launches off
 * the logcat line gave a 70% spread between identical runs of the same file — enough noise
 * to make a single before-and-after comparison meaningless, and enough to let a real
 * regression hide. Repeating a launch ten times and reporting the distribution is the only
 * way to know whether a change to the player's HTTP stack, its buffering or the order of
 * its startup actually moved anything.
 *
 * Two metrics, from the spans in `PlaybackTraceSections`:
 *
 *  - `Memby.playbackLaunch` — the whole wait, from the player opening to the first frame.
 *  - `Memby.playbackFirstFrame` — `prepare()` to the first frame: opening the file over
 *    HTTP, seeking, and starting a decoder. On a resume this is the overwhelming majority
 *    of the wait, so a change that moves the first number without moving this one has
 *    almost certainly moved something else.
 *
 * **This benchmark talks to a real server.** Unlike `HomeBenchmark`, it cannot run against
 * a blank install: the television must be signed in and the Emby server reachable, because
 * what is being measured is largely the network and the decoder. That also means the
 * numbers are only comparable against other runs on *the same TV, server and title* — they
 * are a regression guard, not an absolute.
 *
 * Preconditions, all of which fail the run loudly rather than quietly measuring nothing:
 *
 *  1. Memby signed in, on the Home destination.
 *  2. At least one part-watched item in Continue Watching, so a detail page offers Resume.
 *
 * Run it with the variant task, not `connectedCheck` — the latter also runs
 * `nonMinifiedRelease`, which is the baseline-profile generator's variant, and would
 * measure everything twice:
 *
 * ```
 * $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
 * .\gradlew.bat :benchmark:connectedBenchmarkReleaseAndroidTest `
 *   -P android.testInstrumentationRunnerArguments.class=com.ponzischeme89.memby.benchmark.PlaybackBenchmark
 * ```
 */
@OptIn(ExperimentalMetricApi::class)
@RunWith(AndroidJUnit4::class)
class PlaybackBenchmark {

    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    /**
     * The case this was all built for: picking something up part-way through.
     *
     * The browsing is deliberately in `setupBlock` and therefore unmeasured. What is timed
     * is one keypress on Resume and the wait that follows it, which is the thing a viewer
     * is actually sitting through.
     */
    @Test
    fun resumeFromContinueWatching() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(
            TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
            TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
        ),
        compilationMode = CompilationMode.Partial(),
        startupMode = StartupMode.WARM,
        iterations = ITERATIONS,
        setupBlock = {
            pressHome()
            startActivityAndWait()
            openResumableDetailPage()
        },
        measureBlock = { playAndWaitForFirstFrame() },
    )

    /**
     * The same measurement without a seek, as the control. A resume differs from a cold
     * start in exactly one way that matters — the file is opened part-way through — so the
     * pair is what says whether a change helped seeking or helped everything.
     *
     * The hero's Play button is used because it is the one target on the launcher that is
     * always exactly one D-pad press away, which keeps this from measuring navigation luck.
     */
    @Test
    fun coldStartFromHomeHero() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(
            TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
            TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
        ),
        compilationMode = CompilationMode.Partial(),
        startupMode = StartupMode.WARM,
        iterations = ITERATIONS,
        setupBlock = {
            pressHome()
            startActivityAndWait()
            device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
            device.waitForIdle()
        },
        measureBlock = { playAndWaitForFirstFrame() },
    )

    /**
     * Presses the focused Play button and waits for the picture.
     *
     * The end of the wait is the loading overlay going away, which `PlayerActivity` does
     * from `onRenderedFirstFrame` — the same moment that closes the trace spans. The wait
     * is only here to stop the capture ending mid-launch; the spans, not this, are what is
     * reported.
     */
    private fun MacrobenchmarkScope.playAndWaitForFirstFrame() {
        device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
        val started = device.wait(
            Until.hasObject(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
            PLAYER_APPEAR_TIMEOUT_MS,
        )
        // A launch that reused a prefetched stream can be past the overlay before the first
        // poll, so its absence is not a failure — only never getting a picture is.
        if (started != null) {
            check(
                device.wait(
                    Until.gone(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
                    FIRST_FRAME_TIMEOUT_MS,
                ),
            ) { "No picture within ${FIRST_FRAME_TIMEOUT_MS}ms — is the server reachable?" }
        }
        device.waitForIdle()
    }

    /**
     * Walks the Continue Watching row until a detail page offers Resume, and leaves it
     * open with that button focused.
     *
     * It searches rather than assuming a position because the row reorders itself as the
     * household watches things — the item that was second yesterday is first today. If
     * nothing in the row offers a Resume the run is skipped with a message, rather than
     * silently measuring a cold start and reporting it as a resume.
     */
    private fun MacrobenchmarkScope.openResumableDetailPage() {
        device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
        repeat(2) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN) }
        device.waitForIdle()

        repeat(CARDS_TO_TRY) {
            device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
            if (device.wait(Until.hasObject(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS) != null) {
                device.waitForIdle()
                return
            }
            // Not a resumable item. Close the detail page and try the next card along.
            device.pressBack()
            device.wait(Until.gone(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS)
            device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
            device.waitForIdle()
        }
        assumeTrue(
            "No part-watched item found in Continue Watching — this benchmark needs one.",
            false,
        )
    }

    private companion object {
        const val PACKAGE_NAME = "com.ponzischeme89.memby"

        // Kept identical to PlaybackTraceSections in :app. They cannot be imported — the
        // benchmark is a separate module that does not depend on the app's code — so a
        // rename there has to be repeated here, and the assertion in
        // PlaybackTraceNamesTest is what makes that impossible to forget.
        const val PLAYBACK_LAUNCH = "Memby.playbackLaunch"
        const val PLAYBACK_FIRST_FRAME = "Memby.playbackFirstFrame"

        const val PLAYER_LOADING_ID = "playback_loading"
        const val RESUME_LABEL = "Resume"

        /** Ten launches is enough to see the distribution through the run-to-run spread. */
        const val ITERATIONS = 10
        const val CARDS_TO_TRY = 6
        const val PLAYER_APPEAR_TIMEOUT_MS = 3_000L
        const val DETAIL_TIMEOUT_MS = 5_000L
        const val FIRST_FRAME_TIMEOUT_MS = 30_000L
    }
}
