/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
 * Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

package org.mozilla.geckoview.test

import android.graphics.SurfaceTexture
import android.net.Uri
import android.view.PointerIcon
import android.view.Surface
import androidx.annotation.AnyThread
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
import org.json.JSONObject
import org.junit.Assume.assumeThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
import org.mozilla.geckoview.ContentBlocking.CookieBannerMode
import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo
import org.mozilla.geckoview.GeckoSession.ContentDelegate
import org.mozilla.geckoview.GeckoSession.NavigationDelegate
import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
import org.mozilla.geckoview.GeckoSession.ProgressDelegate
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
import java.io.ByteArrayInputStream

@RunWith(AndroidJUnit4::class)
@MediumTest
class ContentDelegateTest : BaseSessionTest() {
    @Test fun titleChange() {
        mainSession.loadTestPath(TITLE_CHANGE_HTML_PATH)

        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 2)
            override fun onTitleChange(session: GeckoSession, title: String?) {
                assertThat(
                    "Title should match",
                    title,
                    equalTo(forEachCall("Title1", "Title2")),
                )
            }
        })
    }

    @Test fun openInAppRequest() {
        // Testing WebResponse behavior
        val data = "Hello, World.".toByteArray()
        val fileHeader = "attachment; filename=\"hello-world.txt\""
        val requestExternal = true
        val skipConfirmation = true
        var response = WebResponse.Builder(HELLO_HTML_PATH)
            .statusCode(200)
            .body(ByteArrayInputStream(data))
            .addHeader("Content-Type", "application/txt")
            .addHeader("Content-Length", data.size.toString())
            .addHeader("Content-Disposition", fileHeader)
            .requestExternalApp(requestExternal)
            .skipConfirmation(skipConfirmation)
            .build()
        assertThat(
            "Filename matches as expected",
            response.headers["Content-Disposition"],
            equalTo(fileHeader),
        )
        assertThat(
            "Request external response matches as expected.",
            requestExternal,
            equalTo(response.requestExternalApp),
        )
        assertThat(
            "Skipping the confirmation matches as expected.",
            skipConfirmation,
            equalTo(response.skipConfirmation),
        )
    }

    @Test fun downloadOneRequest() {
        // disable test on pgo for frequently failing Bug 1543355
        assumeThat(sessionRule.env.isDebugBuild, equalTo(true))

        mainSession.loadTestPath(DOWNLOAD_HTML_PATH)

        sessionRule.waitUntilCalled(object : NavigationDelegate, ContentDelegate {

            @AssertCalled(count = 2)
            override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
                return null
            }

            @AssertCalled(false)
            override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
                return null
            }

            @AssertCalled(count = 1)
            override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
                assertThat("Uri should start with data:", response.uri, startsWith("blob:"))
                assertThat("We should download the thing", String(response.body?.readBytes()!!), equalTo("Downloaded Data"))
                // The headers below are special headers that we try to get for responses of any kind (http, blob, etc.)
                // Note the case of the header keys. In the WebResponse object, all of them are lower case.
                assertThat("Content type should match", response.headers.get("content-type"), equalTo("text/plain"))
                assertThat("Content length should be non-zero", response.headers.get("Content-Length")!!.toLong(), greaterThan(0L))
                assertThat("Filename should match", response.headers.get("cONTent-diSPOsiTion"), equalTo("attachment; filename=\"download.txt\""))
                assertThat("Request external response should not be set.", response.requestExternalApp, equalTo(false))
                assertThat("Should not skip the confirmation on a regular download.", response.skipConfirmation, equalTo(false))
            }
        })
    }

    @IgnoreCrash
    @Test
    fun crashContent() {
        // TODO: bug 1710940
        assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))

        mainSession.loadUri(CONTENT_CRASH_URL)
        mainSession.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onCrash(session: GeckoSession) {
                assertThat(
                    "Session should be closed after a crash",
                    session.isOpen,
                    equalTo(false),
                )
            }
        })

        // Recover immediately
        mainSession.open()
        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitUntilCalled(object : ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("Page should load successfully", success, equalTo(true))
            }
        })
    }

    @IgnoreCrash
    @WithDisplay(width = 10, height = 10)
    @Test
    fun crashContent_tapAfterCrash() {
        // TODO: bug 1710940
        assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))

        mainSession.delegateUntilTestEnd(object : ContentDelegate {
            override fun onCrash(session: GeckoSession) {
                mainSession.open()
                mainSession.loadTestPath(HELLO_HTML_PATH)
            }
        })

        mainSession.synthesizeTap(5, 5)
        mainSession.loadUri(CONTENT_CRASH_URL)
        mainSession.waitForPageStop()

        mainSession.synthesizeTap(5, 5)
        mainSession.reload()
        mainSession.waitForPageStop()
    }

    @AnyThread
    fun killAllContentProcesses() {
        val contentProcessPids = sessionRule.getAllSessionPids()
        for (pid in contentProcessPids) {
            sessionRule.killContentProcess(pid)
        }
    }

    @IgnoreCrash
    @Test
    fun killContent() {
        killAllContentProcesses()
        mainSession.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onKill(session: GeckoSession) {
                assertThat(
                    "Session should be closed after being killed",
                    session.isOpen,
                    equalTo(false),
                )
            }
        })

        mainSession.open()
        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitUntilCalled(object : ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("Page should load successfully", success, equalTo(true))
            }
        })
    }

    private fun goFullscreen() {
        sessionRule.setPrefsUntilTestEnd(mapOf("full-screen-api.allow-trusted-requests-only" to false))
        mainSession.loadTestPath(FULLSCREEN_PATH)
        mainSession.waitForPageStop()
        val promise = mainSession.evaluatePromiseJS("document.querySelector('#fullscreen').requestFullscreen()")
        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
                assertThat("Div went fullscreen", fullScreen, equalTo(true))
            }
        })
        promise.value
    }

    private fun waitForFullscreenExit() {
        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
                assertThat("Div left fullscreen", fullScreen, equalTo(false))
            }
        })
    }

    @Test fun fullscreen() {
        goFullscreen()
        val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()")
        waitForFullscreenExit()
        promise.value
    }

    @Test fun sessionExitFullscreen() {
        goFullscreen()
        mainSession.exitFullScreen()
        waitForFullscreenExit()
    }

    @Test fun firstComposite() {
        val display = mainSession.acquireDisplay()
        val texture = SurfaceTexture(0)
        texture.setDefaultBufferSize(100, 100)
        val surface = Surface(texture)
        display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build())
        mainSession.loadTestPath(HELLO_HTML_PATH)
        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onFirstComposite(session: GeckoSession) {
            }
        })
        display.surfaceDestroyed()
        display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build())
        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onFirstComposite(session: GeckoSession) {
            }
        })
        display.surfaceDestroyed()
        mainSession.releaseDisplay(display)
    }

    @WithDisplay(width = 10, height = 10)
    @Test
    fun firstContentfulPaint() {
        mainSession.loadTestPath(HELLO_HTML_PATH)
        sessionRule.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onFirstContentfulPaint(session: GeckoSession) {
            }
        })
    }

    @Test fun webAppManifestPref() {
        val initialState = sessionRule.runtime.settings.getWebManifestEnabled()
        val jsToRun = "document.querySelector('link[rel=manifest]').relList.supports('manifest');"

        // Check pref'ed off
        sessionRule.runtime.settings.setWebManifestEnabled(false)
        mainSession.loadTestPath(HELLO_HTML_PATH)
        sessionRule.waitForPageStop(mainSession)

        var result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean)

        assertThat("Disabling pref makes relList.supports('manifest') return false", false, result)

        // Check pref'ed on
        sessionRule.runtime.settings.setWebManifestEnabled(true)
        mainSession.loadTestPath(HELLO_HTML_PATH)
        sessionRule.waitForPageStop(mainSession)

        result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean)
        assertThat("Enabling pref makes relList.supports('manifest') return true", true, result)

        sessionRule.runtime.settings.setWebManifestEnabled(initialState)
    }

    @Test fun webAppManifest() {
        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("Page load should succeed", success, equalTo(true))
            }

            @AssertCalled(count = 1)
            override fun onWebAppManifest(session: GeckoSession, manifest: JSONObject) {
                // These values come from the manifest at assets/www/manifest.webmanifest
                assertThat("name should match", manifest.getString("name"), equalTo("App"))
                assertThat("short_name should match", manifest.getString("short_name"), equalTo("app"))
                assertThat("display should match", manifest.getString("display"), equalTo("standalone"))

                // The color here is "cadetblue" converted to #aarrggbb.
                assertThat("theme_color should match", manifest.getString("theme_color"), equalTo("#ff5f9ea0"))
                assertThat("background_color should match", manifest.getString("background_color"), equalTo("#eec0ffee"))
                assertThat("start_url should match", manifest.getString("start_url"), endsWith("/assets/www/start/index.html"))

                val icon = manifest.getJSONArray("icons").getJSONObject(0)

                val iconSrc = Uri.parse(icon.getString("src"))
                assertThat("icon should have a valid src", iconSrc, notNullValue())
                assertThat("icon src should be absolute", iconSrc.isAbsolute, equalTo(true))
                assertThat("icon should have sizes", icon.getString("sizes"), not(isEmptyOrNullString()))
                assertThat("icon type should match", icon.getString("type"), equalTo("image/gif"))
            }
        })
    }

    @Test fun previewImage() {
        mainSession.loadTestPath(METATAGS_PATH)
        mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPreviewImage(session: GeckoSession, previewImageUrl: String) {
                assertThat("Preview image should match", previewImageUrl, equalTo("https://test.com/og-image-url"))
            }
        })
    }

    @Test fun viewportFit() {
        mainSession.loadTestPath(VIEWPORT_PATH)
        mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("Page load should succeed", success, equalTo(true))
            }

            @AssertCalled(count = 1)
            override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) {
                assertThat("viewport-fit should match", viewportFit, equalTo("cover"))
            }
        })

        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("Page load should succeed", success, equalTo(true))
            }

            @AssertCalled(count = 1)
            override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) {
                assertThat("viewport-fit should match", viewportFit, equalTo("auto"))
            }
        })
    }

    @Test fun closeRequest() {
        if (!sessionRule.env.isAutomation) {
            sessionRule.setPrefsUntilTestEnd(mapOf("dom.allow_scripts_to_close_windows" to true))
        }

        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitForPageStop()

        mainSession.evaluateJS("window.close()")
        mainSession.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onCloseRequest(session: GeckoSession) {
            }
        })
    }

    @Test fun windowOpenClose() {
        sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))

        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitForPageStop()

        val newSession = sessionRule.createClosedSession()
        mainSession.delegateDuringNextWait(object : NavigationDelegate {
            @AssertCalled(count = 1)
            override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
                return GeckoResult.fromValue(newSession)
            }
        })

        mainSession.evaluateJS("const w = window.open('about:blank'); w.close()")

        newSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onCloseRequest(session: GeckoSession) {
            }

            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
            }
        })
    }

    @Test fun cookieBannerDetectedEvent() {
        sessionRule.setPrefsUntilTestEnd(
            mapOf(
                "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
            ),
        )

        val detectHandled = GeckoResult<Void>()
        mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate {
            override fun onCookieBannerDetected(
                session: GeckoSession,
            ) {
                detectHandled.complete(null)
            }
        })

        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitForPageStop()
        mainSession.triggerCookieBannerDetected()

        sessionRule.waitForResult(detectHandled)
    }

    @Test fun cookieBannerHandledEvent() {
        sessionRule.setPrefsUntilTestEnd(
            mapOf(
                "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
            ),
        )

        val handleHandled = GeckoResult<Void>()
        mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate {
            override fun onCookieBannerHandled(
                session: GeckoSession,
            ) {
                handleHandled.complete(null)
            }
        })

        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitForPageStop()
        mainSession.triggerCookieBannerHandled()

        sessionRule.waitForResult(handleHandled)
    }

    @WithDisplay(width = 100, height = 100)
    @Test
    fun setCursor() {
        mainSession.loadTestPath(HELLO_HTML_PATH)
        mainSession.waitForPageStop()

        mainSession.evaluateJS("document.body.style.cursor = 'wait'")
        mainSession.synthesizeMouseMove(50, 50)

        mainSession.waitUntilCalled(object : ContentDelegate {
            @AssertCalled(count = 1)
            override fun onPointerIconChange(session: GeckoSession, icon: PointerIcon) {
                // PointerIcon has no compare method.
            }
        })

        val delegate = mainSession.contentDelegate
        mainSession.contentDelegate = null
        mainSession.evaluateJS("document.body.style.cursor = 'text'")
        for (i in 51..70) {
            mainSession.synthesizeMouseMove(i, 50)
            // No wait function since we remove content delegate.
            mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))")
        }
        mainSession.contentDelegate = delegate
    }

    /**
     * Preferences to induce wanted behaviour.
     */
    private fun setHangReportTestPrefs(timeout: Int = 20000) {
        sessionRule.setPrefsUntilTestEnd(
            mapOf(
                "dom.max_script_run_time" to 1,
                "dom.max_chrome_script_run_time" to 1,
                "dom.max_ext_content_script_run_time" to 1,
                "dom.ipc.cpow.timeout" to 100,
                "browser.hangNotification.waitPeriod" to timeout,
            ),
        )
    }

    /**
     * With no delegate set, the default behaviour is to stop hung scripts.
     */
    @NullDelegate(ContentDelegate::class)
    @Test
    fun stopHungProcessDefault() {
        setHangReportTestPrefs()
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.delegateUntilTestEnd(object : ProgressDelegate {
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat(
                    "The script did not complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Started"),
                )
            }
        })
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * With no overriding implementation for onSlowScript, the default behaviour is to stop hung
     * scripts.
     */
    @Test fun stopHungProcessNull() {
        setHangReportTestPrefs()
        sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate {
            // default onSlowScript returns null
            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat(
                    "The script did not complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Started"),
                )
            }
        })
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * Test that, with a 'do nothing' delegate, the hung process completes after its delay
     */
    @Test fun stopHungProcessDoNothing() {
        setHangReportTestPrefs()
        var scriptHungReportCount = 0
        sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate {
            @AssertCalled()
            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
                scriptHungReportCount += 1
                return GeckoResult.fromValue(null)
            }

            @AssertCalled(count = 1)
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat("The delegate was informed of the hang repeatedly", scriptHungReportCount, greaterThan(1))
                assertThat(
                    "The script did complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Finished"),
                )
            }
        })
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * Test that the delegate is called and can stop a hung script
     */
    @Test fun stopHungProcess() {
        setHangReportTestPrefs()
        sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1, order = [1])
            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
                return GeckoResult.fromValue(SlowScriptResponse.STOP)
            }

            @AssertCalled(count = 1, order = [2])
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat(
                    "The script did not complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Started"),
                )
            }
        })
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * Test that the delegate is called and can continue executing hung scripts
     */
    @Test fun stopHungProcessWait() {
        setHangReportTestPrefs()
        sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 1, order = [1])
            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
                return GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
            }

            @AssertCalled(count = 1, order = [2])
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat(
                    "The script did complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Finished"),
                )
            }
        })
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * Test that the delegate is called and paused scripts re-notify after the wait period
     */
    @Test fun stopHungProcessWaitThenStop() {
        setHangReportTestPrefs(500)
        var scriptWaited = false
        sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate {
            @AssertCalled(count = 2, order = [1, 2])
            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
                return if (!scriptWaited) {
                    scriptWaited = true
                    GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
                } else {
                    GeckoResult.fromValue(SlowScriptResponse.STOP)
                }
            }

            @AssertCalled(count = 1, order = [3])
            override fun onPageStop(session: GeckoSession, success: Boolean) {
                assertThat(
                    "The script did not complete.",
                    mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
                    equalTo("Started"),
                )
            }
        })
        mainSession.loadTestPath(HUNG_SCRIPT)
        sessionRule.waitForPageStop(mainSession)
    }

    /**
     * Test that the display mode is applied to CSS media query
     */
    @Test fun displayMode() {
        val pwaSession = sessionRule.createOpenSession(
            GeckoSessionSettings.Builder(mainSession.settings)
                .displayMode(GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN)
                .build(),
        )
        pwaSession.loadTestPath(HELLO_HTML_PATH)
        pwaSession.waitForPageStop()

        val matches = pwaSession.evaluateJS("window.matchMedia('(display-mode: fullscreen)').matches") as Boolean
        assertThat(
            "display-mode should be fullscreen",
            matches,
            equalTo(true),
        )
    }
}
