/* 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.telemetry.glean.private

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.lang.NullPointerException
import mozilla.telemetry.glean.Dispatchers
import mozilla.telemetry.glean.Glean
import mozilla.telemetry.glean.GleanMetrics.GleanBuildInfo
import mozilla.telemetry.glean.GleanTimerId
import mozilla.telemetry.glean.testing.ErrorType
import mozilla.telemetry.glean.testing.GleanTestRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.spy

@RunWith(AndroidJUnit4::class)
class TimingDistributionMetricTypeTest {

    val context: Context
        get() = ApplicationProvider.getApplicationContext()

    @get:Rule
    val gleanRule = GleanTestRule(ApplicationProvider.getApplicationContext())

    @Test
    fun `The API saves to its storage engine`() {
        // Define a timing distribution metric which will be stored in "store1"
        val metric = spy(TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Nanosecond
        ))

        // Accumulate a few values
        for (i in 1L..3L) {
            `when`(metric.getElapsedTimeNanos()).thenReturn(0L)
            val id = metric.start()
            `when`(metric.getElapsedTimeNanos()).thenReturn(i)
            metric.stopAndAccumulate(id)
        }

        // Check that data was properly recorded.
        assertTrue(metric.testHasValue())
        val snapshot = metric.testGetValue()
        // Check the sum
        assertEquals(6L, snapshot.sum)
        // Check that the 1L fell into the first bucket (max 1)
        assertEquals(1L, snapshot.values[1])
        // Check that the 2L fell into the second bucket (max 2)
        assertEquals(1L, snapshot.values[2])
        // Check that the 3L fell into the third bucket (max 3)
        assertEquals(1L, snapshot.values[3])
    }

    @Test
    fun `disabled timing distributions must not record data`() {
        // Define a timing distribution metric which will be stored in "store1"
        // It's lifetime is set to Lifetime.Ping so it should not record anything.
        val metric = TimingDistributionMetricType(
            disabled = true,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Nanosecond
        )

        // Attempt to store the timespan using set
        val id = metric.start()
        metric.stopAndAccumulate(id)

        // Check that nothing was recorded.
        assertFalse("Disabled TimingDistributions should not record data.",
            metric.testHasValue())
    }

    @Test(expected = NullPointerException::class)
    fun `testGetValue() throws NullPointerException if nothing is stored`() {
        // Define a timing distribution metric which will be stored in "store1"
        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Nanosecond
        )
        metric.testGetValue()
    }

    @Test
    fun `The API saves to secondary pings`() {
        // Define a timing distribution metric which will be stored in multiple stores
        val metric = spy(TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution",
            sendInPings = listOf("store1", "store2", "store3"),
            timeUnit = TimeUnit.Nanosecond
        ))

        // Accumulate a few values
        for (i in 1L..3L) {
            `when`(metric.getElapsedTimeNanos()).thenReturn(0L)
            val id = metric.start()
            `when`(metric.getElapsedTimeNanos()).thenReturn(i)
            metric.stopAndAccumulate(id)
        }

        // Check that data was properly recorded in the second ping.
        assertTrue(metric.testHasValue("store2"))
        val snapshot = metric.testGetValue("store2")
        // Check the sum
        assertEquals(6L, snapshot.sum)
        // Check that the 1L fell into the first bucket
        assertEquals(1L, snapshot.values[1])
        // Check that the 2L fell into the second bucket
        assertEquals(1L, snapshot.values[2])
        // Check that the 3L fell into the third bucket
        assertEquals(1L, snapshot.values[3])

        // Check that data was properly recorded in the third ping.
        assertTrue(metric.testHasValue("store3"))
        val snapshot2 = metric.testGetValue("store3")
        // Check the sum
        assertEquals(6L, snapshot2.sum)
        // Check that the 1L fell into the first bucket
        assertEquals(1L, snapshot2.values[1])
        // Check that the 2L fell into the second bucket
        assertEquals(1L, snapshot2.values[2])
        // Check that the 3L fell into the third bucket
        assertEquals(1L, snapshot2.values[3])
    }

    @Test
    fun `The accumulateSamples API correctly stores timing values`() {
        // Define a timing distribution metric
        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Second
        )

        // Accumulate a few values
        val testSamples = (1L..3L).toList().toLongArray()
        metric.accumulateSamples(testSamples)

        // Check that data was properly recorded in the second ping.
        assertTrue(metric.testHasValue("store1"))
        val snapshot = metric.testGetValue("store1")
        val secondsToNanos = 1000L * 1000L * 1000L
        // Check the sum
        assertEquals(6L * secondsToNanos, snapshot.sum)

        // We should get a sample in 3 buckets.
        // These numbers are a bit magic, but they correspond to
        // `hist.sample_to_bucket_minimum(i * seconds_to_nanos)` for `i = 1..=3`,
        // which lives in the Rust code.
        assertEquals(1L, snapshot.values[984625593])
        assertEquals(1L, snapshot.values[1969251187])
        assertEquals(1L, snapshot.values[2784941737])
    }

    @Test
    fun `Starting a timer before initialization doesn't crash`() {
        Glean.testDestroyGleanHandle()
        @Suppress("EXPERIMENTAL_API_USAGE")
        Dispatchers.API.setTaskQueueing(true)

        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Second
        )

        val timerId = metric.start()
        Glean.initialize(context, true, buildInfo = GleanBuildInfo.buildInfo)
        metric.stopAndAccumulate(timerId)

        metric.testGetValue().sum >= 0
    }

    @Test
    fun `Starting and stopping a timer before initialization doesn't crash`() {
        Glean.testDestroyGleanHandle()
        @Suppress("EXPERIMENTAL_API_USAGE")
        Dispatchers.API.setTaskQueueing(true)

        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Second
        )

        val timerId = metric.start()
        metric.stopAndAccumulate(timerId)
        Glean.initialize(context, true, buildInfo = GleanBuildInfo.buildInfo)

        metric.testGetValue().sum >= 0
    }

    @Test
    fun `Stopping a non-existent timer records an error`() {
        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Second
        )

        metric.stopAndAccumulate(GleanTimerId(-1))
        assertEquals(1, metric.testGetNumRecordedErrors(ErrorType.InvalidState))
    }

    @Test
    fun `measure function correctly measures values`() {
        val metric = spy(TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Nanosecond
        ))

        // Create a test function to "measure". This works by mocking the getElapsedNanos return
        // value setting it to return a known value to make it easier to validate.
        fun testFunc(value: Long): Long {
            `when`(metric.getElapsedTimeNanos()).thenReturn(value)
            return value
        }

        // Accumulate a few values
        for (i in 1L..3L) {
            // Measure the test function, capturing the value to verify we correctly return the
            // value of the underlying function.
            `when`(metric.getElapsedTimeNanos()).thenReturn(0L)
            val testValue = metric.measure {
                testFunc(i)
            }

            assertEquals("Returned value must match", i, testValue)
        }

        // Check that data was properly recorded.
        assertTrue(metric.testHasValue())
        val snapshot = metric.testGetValue()
        // Check the sum
        assertEquals(6L, snapshot.sum)
        // Check that the 1L fell into the first bucket (max 1)
        assertEquals(1L, snapshot.values[1])
        // Check that the 2L fell into the second bucket (max 2)
        assertEquals(1L, snapshot.values[2])
        // Check that the 3L fell into the third bucket (max 3)
        assertEquals(1L, snapshot.values[3])
    }

    @Test
    fun `measure function does not change behavior with early return`() {
        val metric = spy(TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "inlined",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Nanosecond
        ))

        // We define a function that measures the whole function call runtime
        fun testFunc(): Long = metric.measure {
            // Stop should call `getElapsedTimeNanos` again,
            // so we give it a later timestamp
            `when`(metric.getElapsedTimeNanos()).thenReturn(10L)

            // We want to simulate an early return.
            if (true) {
                // Blank 'return' is not allowed here, because `measure` is not inlined.
                // We can return by label though.
                return@measure 17
            }

            42
        }

        // We start time at `0`
        `when`(metric.getElapsedTimeNanos()).thenReturn(0L)

        val res = testFunc()
        assertEquals("Test value must match", 17, res)

        assertTrue("Metric must have a value", metric.testHasValue())
        val snapshot = metric.testGetValue()
        assertEquals("Should have stored 10 nanoseconds", 10L, snapshot.sum)
    }

    @Test
    fun `measure function bubbles up exceptions and timing is canceled`() {
        val metric = TimingDistributionMetricType(
            disabled = false,
            category = "telemetry",
            lifetime = Lifetime.Ping,
            name = "timing_distribution_samples",
            sendInPings = listOf("store1"),
            timeUnit = TimeUnit.Second
        )

        // Create a test function that throws a NPE
        fun testFunc() {
            throw NullPointerException()
        }

        // Attempt to measure the function that will throw an exception.  The `measure` function
        // should allow the exception to bubble up, the timespan measurement is canceled.
        try {
            metric.measure {
                testFunc()
            }
        } catch (e: Exception) {
            // Ensure that the exception was a NPE
            assertTrue("Exception type must match", e is NullPointerException)
        } finally {
            // Check that data was still properly recorded even though there was an exception.
            assertTrue("Metric must not have a value", !metric.testHasValue())
        }
    }

    @Test
    fun `ensure that time_unit controls truncation`() {
        val maxSampleTime = 1000L * 1000 * 1000 * 60 * 10

        for (unit in listOf(
                TimeUnit.Nanosecond,
                TimeUnit.Microsecond,
                TimeUnit.Millisecond
        )) {
            val metric = spy(TimingDistributionMetricType(
                disabled = false,
                category = "telemetry",
                lifetime = Lifetime.Ping,
                name = "test_${unit.name}",
                sendInPings = listOf("store1"),
                timeUnit = unit
            ))

            for (value in listOf(
                    1L,
                    1000L,
                    100000L,
                    maxSampleTime,
                    maxSampleTime * 1000L,
                    maxSampleTime * 1000000L
            )) {
                `when`(metric.getElapsedTimeNanos()).thenReturn(0L)
                val timerId = metric.start()
                `when`(metric.getElapsedTimeNanos()).thenReturn(value)
                metric.stopAndAccumulate(timerId)
            }

            val snapshot = metric.testGetValue()
            assertTrue(snapshot.values.size < 318)
        }
    }
}
