package io.sentry.android.replay.capture

import android.graphics.Bitmap
import io.sentry.Breadcrumb
import io.sentry.DateUtils
import io.sentry.IScopes
import io.sentry.Scope
import io.sentry.ScopeCallback
import io.sentry.SentryOptions
import io.sentry.SentryReplayEvent
import io.sentry.SentryReplayEvent.ReplayType
import io.sentry.SentryReplayOptions.SentryReplayQuality.HIGH
import io.sentry.android.replay.BuildConfig
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter
import io.sentry.android.replay.GeneratedVideo
import io.sentry.android.replay.ReplayCache
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH
import io.sentry.android.replay.ReplayFrame
import io.sentry.android.replay.ScreenshotRecorderConfig
import io.sentry.android.replay.maskAllImages
import io.sentry.protocol.SentryId
import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebMetaEvent
import io.sentry.rrweb.RRWebOptionsEvent
import io.sentry.transport.CurrentDateProvider
import io.sentry.transport.ICurrentDateProvider
import java.io.File
import java.util.Date
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.check
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

class SessionCaptureStrategyTest {
  @get:Rule val tmpDir = TemporaryFolder()

  internal class Fixture {
    companion object {
      const val VIDEO_DURATION = 5000L
    }

    val options =
      SentryOptions().apply {
        setReplayController(
          mock { on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) }
        )
      }
    val scope = Scope(options)
    val scopes =
      mock<IScopes> {
        doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }
          .whenever(it)
          .configureScope(any())
      }
    var persistedSegment = LinkedHashMap<String, String?>()
    val replayCache =
      mock<ReplayCache> {
        on { frames }
          .thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997)))
        on { persistSegmentValues(any(), anyOrNull()) }
          .then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) }
        on {
            createVideoOf(
              anyLong(),
              anyLong(),
              anyInt(),
              anyInt(),
              anyInt(),
              anyInt(),
              anyInt(),
              any(),
            )
          }
          .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION))
      }
    val recorderConfig =
      ScreenshotRecorderConfig(
        recordingWidth = 1080,
        recordingHeight = 1920,
        scaleFactorX = 1f,
        scaleFactorY = 1f,
        frameRate = 1,
        bitRate = 20_000,
      )

    fun getSut(
      dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(),
      replayCacheDir: File? = null,
    ): SessionCaptureStrategy {
      replayCacheDir?.let { whenever(replayCache.replayCacheDir).thenReturn(it) }
      return SessionCaptureStrategy(
        options,
        scopes,
        dateProvider,
        mock {
          doAnswer { invocation ->
              (invocation.arguments[0] as Runnable).run()
              null
            }
            .whenever(it)
            .submit(any<Runnable>())
        },
        mock {
          doAnswer { invocation ->
              (invocation.arguments[0] as Runnable).run()
              null
            }
            .whenever(it)
            .submit(any<Runnable>())
        },
      ) { _ ->
        replayCache
      }
    }
  }

  private val fixture = Fixture()

  @Test
  fun `start sets replayId on scope for full session`() {
    val strategy = fixture.getSut()
    val replayId = SentryId()

    strategy.start(0, replayId)

    assertEquals(replayId, fixture.scope.replayId)
    assertEquals(replayId, strategy.currentReplayId)
    assertEquals(0, strategy.currentSegment)
  }

  @Test
  fun `start persists segment values`() {
    val strategy = fixture.getSut()
    val replayId = SentryId()

    strategy.start(0, replayId)
    strategy.onConfigurationChanged(fixture.recorderConfig)

    assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID])
    assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID])
    assertEquals(ReplayType.SESSION.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE])
    assertEquals(
      fixture.recorderConfig.recordingWidth.toString(),
      fixture.persistedSegment[SEGMENT_KEY_WIDTH],
    )
    assertEquals(
      fixture.recorderConfig.recordingHeight.toString(),
      fixture.persistedSegment[SEGMENT_KEY_HEIGHT],
    )
    assertEquals(
      fixture.recorderConfig.frameRate.toString(),
      fixture.persistedSegment[SEGMENT_KEY_FRAME_RATE],
    )
    assertEquals(
      fixture.recorderConfig.bitRate.toString(),
      fixture.persistedSegment[SEGMENT_KEY_BIT_RATE],
    )
    assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true)
  }

  @Test
  fun `pause creates and captures current segment`() {
    val strategy = fixture.getSut()
    strategy.start(0, SentryId())
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.pause()

    verify(fixture.scopes)
      .captureReplay(argThat { event -> event is SentryReplayEvent && event.segmentId == 0 }, any())
    assertEquals(1, strategy.currentSegment)
  }

  @Test
  fun `stop creates and captures current segment and clears replayId from scope`() {
    val replayId = SentryId()
    val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() }

    val strategy = fixture.getSut(replayCacheDir = currentReplay)
    strategy.start(0, replayId)
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.stop()

    verify(fixture.scopes)
      .captureReplay(argThat { event -> event is SentryReplayEvent && event.segmentId == 0 }, any())
    assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId)
    assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId)
    assertEquals(-1, strategy.currentSegment)
    assertFalse(currentReplay.exists())
    verify(fixture.replayCache).close()
  }

  @Test
  fun `captureReplay does nothing for non-crashed event`() {
    val strategy = fixture.getSut()
    strategy.start()

    strategy.captureReplay(false) {}

    verify(fixture.scopes, never()).captureReplay(any(), any())
  }

  @Test
  fun `when process is crashing, onScreenshotRecorded does not create new segment`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()

    strategy.captureReplay(true) {}
    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes, never()).captureReplay(any(), any())
  }

  @Test
  fun `onScreenshotRecorded creates new segment when segment duration exceeded`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.onScreenshotRecorded(mock<Bitmap>()) { frameTimestamp ->
      assertEquals(now, frameTimestamp)
    }

    var segmentTimestamp: Date? = null
    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          segmentTimestamp = event.replayStartTimestamp
          event is SentryReplayEvent && event.segmentId == 0
        },
        any(),
      )
    assertEquals(1, strategy.currentSegment)

    segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION)
    // timestamp should be updated with video duration
    assertEquals(
      DateUtils.getTimestamp(segmentTimestamp!!),
      fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP],
    )
  }

  @Test
  fun `onScreenshotRecorded stops replay when replay duration exceeded`() {
    val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2)
    var count = 0
    val strategy =
      fixture.getSut(
        dateProvider = {
          // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and
          // frameTimestamp)
          if (count++ == 2) {
            now
          } else {
            System.currentTimeMillis()
          }
        }
      )
    strategy.start()
    strategy.onConfigurationChanged(mock<ScreenshotRecorderConfig>())

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.options.replayController).stop()
  }

  @Test
  fun `onConfigurationChanged creates new segment and updates config`() {
    val strategy = fixture.getSut()
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920)
    strategy.onConfigurationChanged(newConfig)

    var segmentTimestamp: Date? = null
    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          segmentTimestamp = event.replayStartTimestamp
          event is SentryReplayEvent && event.segmentId == 0
        },
        check {
          val metaEvents = it.replayRecording?.payload?.filterIsInstance<RRWebMetaEvent>()
          // should still capture with the old values
          assertEquals(1920, metaEvents?.first()?.height)
          assertEquals(1080, metaEvents?.first()?.width)
        },
      )
    assertEquals(1, strategy.currentSegment)

    segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION)
    assertEquals("1080", fixture.persistedSegment[SEGMENT_KEY_HEIGHT])
    assertEquals("1920", fixture.persistedSegment[SEGMENT_KEY_WIDTH])
    // timestamp should be updated with video duration
    assertEquals(
      DateUtils.getTimestamp(segmentTimestamp!!),
      fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP],
    )
  }

  @Test
  fun `fills replay urls from navigation breadcrumbs`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to"))

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        check { assertEquals("to", it.urls!!.first()) },
        check {
          val breadcrumbEvents =
            it.replayRecording?.payload?.filterIsInstance<RRWebBreadcrumbEvent>()
          assertEquals("navigation", breadcrumbEvents?.first()?.category)
          assertEquals("to", breadcrumbEvents?.first()?.data?.get("to"))
        },
      )
  }

  @Test
  fun `does not throw when navigation destination is not a String`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" })

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        check { assertNull(it.urls?.firstOrNull()) },
        check {
          val breadcrumbEvents =
            it.replayRecording?.payload?.filterIsInstance<RRWebBreadcrumbEvent>()
          assertEquals("navigation", breadcrumbEvents?.first()?.category)
          assertNull(breadcrumbEvents?.first()?.data?.get("to"))
        },
      )
  }

  @Test
  fun `sets screen from scope as replay url`() {
    fixture.scope.screen = "MainActivity"

    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        check { assertEquals("MainActivity", it.urls!!.first()) },
        check {
          val breadcrumbEvents =
            it.replayRecording?.payload?.filterIsInstance<RRWebBreadcrumbEvent>()
          assertTrue(breadcrumbEvents?.isEmpty() == true)
        },
      )
  }

  @Test
  fun `replayId should be set and serialized first`() {
    val strategy = fixture.getSut()
    val replayId = SentryId()

    strategy.start(0, replayId)

    assertEquals(
      replayId.toString(),
      fixture.persistedSegment.values.first(),
      "The replayId must be set first, so when we clean up stale replays" +
        "the current replay cache folder is not being deleted.",
    )
  }

  @Test
  fun `records replay options event for segment 0`() {
    fixture.options.sessionReplay.sessionSampleRate = 1.0
    fixture.options.sessionReplay.maskAllImages = false
    fixture.options.sessionReplay.quality = HIGH
    fixture.options.sessionReplay.addMaskViewClass("my.custom.View")

    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event -> event is SentryReplayEvent && event.segmentId == 0 },
        check {
          val optionsEvent = it.replayRecording?.payload?.filterIsInstance<RRWebOptionsEvent>()!!
          assertEquals("sentry.java", optionsEvent[0].optionsPayload["nativeSdkName"])
          assertEquals(BuildConfig.VERSION_NAME, optionsEvent[0].optionsPayload["nativeSdkVersion"])

          assertEquals(null, optionsEvent[0].optionsPayload["errorSampleRate"])
          assertEquals(1.0, optionsEvent[0].optionsPayload["sessionSampleRate"])
          assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"])
          assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"])
          assertEquals("high", optionsEvent[0].optionsPayload["quality"])
          assertContentEquals(
            listOf(
              "android.widget.TextView",
              "android.webkit.WebView",
              "android.widget.VideoView",
              "androidx.camera.view.PreviewView",
              "androidx.media3.ui.PlayerView",
              "com.google.android.exoplayer2.ui.PlayerView",
              "com.google.android.exoplayer2.ui.StyledPlayerView",
              "my.custom.View",
            ),
            optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*>,
          )
          assertContentEquals(
            listOf("android.widget.ImageView"),
            optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*>,
          )
        },
      )
  }

  @Test
  fun `does not record replay options event for segment above 0`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}
    verify(fixture.scopes)
      .captureReplay(argThat { event -> event is SentryReplayEvent && event.segmentId == 0 }, any())

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}
    verify(fixture.scopes)
      .captureReplay(
        argThat { event -> event is SentryReplayEvent && event.segmentId == 1 },
        check {
          val optionsEvent = it.replayRecording?.payload?.find { it is RRWebOptionsEvent }
          assertNull(optionsEvent)
        },
      )
  }

  @Test
  fun `registerTraceId includes trace IDs in next segment`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    val traceId1 = SentryId()
    val traceId2 = SentryId()
    strategy.registerTraceId(traceId1)
    strategy.registerTraceId(traceId2)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          event is SentryReplayEvent &&
            event.traceIds?.size == 2 &&
            event.traceIds!!.contains(traceId1.toString()) &&
            event.traceIds!!.contains(traceId2.toString())
        },
        any(),
      )
  }

  @Test
  fun `registerTraceId clears trace IDs after segment is created`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    val traceId = SentryId()
    strategy.registerTraceId(traceId)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          event is SentryReplayEvent && event.traceIds?.contains(traceId.toString()) == true
        },
        any(),
      )

    // trigger another segment, trace IDs should be cleared
    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          event is SentryReplayEvent && event.segmentId == 1 && event.traceIds.isNullOrEmpty()
        },
        any(),
      )
  }

  @Test
  fun `registerTraceId ignores empty trace ID`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.registerTraceId(SentryId.EMPTY_ID)

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event -> event is SentryReplayEvent && event.traceIds.isNullOrEmpty() },
        any(),
      )
  }

  @Test
  fun `registerSegmentName includes unique segment names in next segment and clears them`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.registerSegmentName("CheckoutActivity")
    strategy.registerSegmentName("CheckoutActivity")
    strategy.registerSegmentName("ProductDetailsActivity")

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          event is SentryReplayEvent &&
            event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity")
        },
        any(),
      )

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event ->
          event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty()
        },
        any(),
      )
  }

  @Test
  fun `registerSegmentName ignores empty names and limits names to 100`() {
    val now =
      System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
    val strategy = fixture.getSut(dateProvider = { now })
    strategy.start()
    strategy.onConfigurationChanged(fixture.recorderConfig)

    strategy.registerSegmentName("")
    repeat(101) { strategy.registerSegmentName("ProductActivity$it") }

    strategy.onScreenshotRecorded(mock<Bitmap>()) {}

    verify(fixture.scopes)
      .captureReplay(
        argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 },
        any(),
      )
  }
}
