import "./killFinalizationRegistry"
import { act, cleanup, render } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"
import ReactDOM from "react-dom"

import { useObserver } from "../src/useObserver"
import {
    forceCleanupTimerToRunNowForTests,
    resetCleanupScheduleForTests
} from "../src/utils/reactionCleanupTracking"
import {
    CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS,
    CLEANUP_TIMER_LOOP_MILLIS
} from "../src/utils/reactionCleanupTrackingCommon"

afterEach(cleanup)

test("uncommitted components should not leak observations", async () => {
    resetCleanupScheduleForTests()

    // Unfortunately, Jest fake timers don't mock out Date.now, so we fake
    // that out in parallel to Jest useFakeTimers
    let fakeNow = Date.now()
    jest.useFakeTimers()
    jest.spyOn(Date, "now").mockImplementation(() => fakeNow)

    const store = mobx.observable({ count1: 0, count2: 0 })

    // Track whether counts are observed
    let count1IsObserved = false
    let count2IsObserved = false
    mobx.onBecomeObserved(store, "count1", () => (count1IsObserved = true))
    mobx.onBecomeUnobserved(store, "count1", () => (count1IsObserved = false))
    mobx.onBecomeObserved(store, "count2", () => (count2IsObserved = true))
    mobx.onBecomeUnobserved(store, "count2", () => (count2IsObserved = false))

    const TestComponent1 = () => useObserver(() => <div>{store.count1}</div>)
    const TestComponent2 = () => useObserver(() => <div>{store.count2}</div>)

    // Render, then remove only #2
    const rendering = render(
        <React.StrictMode>
            <TestComponent1 />
            <TestComponent2 />
        </React.StrictMode>
    )
    rendering.rerender(
        <React.StrictMode>
            <TestComponent1 />
        </React.StrictMode>
    )

    // Allow any reaction-disposal cleanup timers to run
    const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS)
    fakeNow += skip
    jest.advanceTimersByTime(skip)

    // count1 should still be being observed by Component1,
    // but count2 should have had its reaction cleaned up.
    expect(count1IsObserved).toBeTruthy()
    expect(count2IsObserved).toBeFalsy()
})

test("cleanup timer should not clean up recently-pended reactions", () => {
    // If we're not careful with timings, it's possible to get the
    // following scenario:
    // 1. Component instance A is being created; it renders, we put its reaction R1 into the cleanup list
    // 2. Strict/Concurrent mode causes that render to be thrown away
    // 3. Component instance A is being created; it renders, we put its reaction R2 into the cleanup list
    // 4. The MobX reaction timer from 5 seconds ago kicks in and cleans up all reactions from uncommitted
    //    components, including R1 and R2
    // 5. The commit phase runs for component A, but reaction R2 has already been disposed. Game over.

    // This unit test attempts to replicate that scenario:
    resetCleanupScheduleForTests()

    // Unfortunately, Jest fake timers don't mock out Date.now, so we fake
    // that out in parallel to Jest useFakeTimers
    const fakeNow = Date.now()
    jest.useFakeTimers()
    jest.spyOn(Date, "now").mockImplementation(() => fakeNow)

    const store = mobx.observable({ count: 0 })

    // Track whether the count is observed
    let countIsObserved = false
    mobx.onBecomeObserved(store, "count", () => (countIsObserved = true))
    mobx.onBecomeUnobserved(store, "count", () => (countIsObserved = false))

    const TestComponent1 = () => useObserver(() => <div>{store.count}</div>)

    // We're going to render directly using ReactDOM, not react-testing-library, because we want
    // to be able to get in and do nasty things before everything (including useEffects) have completed,
    // and react-testing-library waits for all that, using act().

    const rootNode = document.createElement("div")
    ReactDOM.render(
        // We use StrictMode here, but it would be helpful to switch this to use real
        // concurrent mode: we don't have a true async render right now so this test
        // isn't as thorough as it could be.
        <React.StrictMode>
            <TestComponent1 />
        </React.StrictMode>,
        rootNode
    )

    // We need to trigger our cleanup timer to run. We can't do this simply
    // by running all jest's faked timers as that would allow the scheduled
    // `useEffect` calls to run, and we want to simulate our cleanup timer
    // getting in between those stages.

    // We force our cleanup loop to run even though enough time hasn't _really_
    // elapsed.  In theory, it won't do anything because not enough time has
    // elapsed since the reactions were queued, and so they won't be disposed.
    forceCleanupTimerToRunNowForTests()

    // Advance time enough to allow any timer-queued effects to run
    jest.advanceTimersByTime(500)

    // Now allow the useEffect calls to run to completion.
    act(() => {
        // no-op, but triggers effect flushing
    })

    // count should still be observed
    expect(countIsObserved).toBeTruthy()
})

test("component should recreate reaction if necessary", () => {
    // There _may_ be very strange cases where the reaction gets tidied up
    // but is actually still needed.  This _really_ shouldn't happen.
    // e.g. if we're using Suspense and the component starts to render,
    // but then gets paused for 60 seconds, and then comes back to life.
    // With the implementation of React at the time of writing this, React
    // will actually fully re-render that component (discarding previous
    // hook slots) before going ahead with a commit, but it's unwise
    // to depend on such an implementation detail.  So we must cope with
    // the component having had its reaction tidied and still going on to
    // be committed.  In that case we recreate the reaction and force
    // an update.

    // This unit test attempts to replicate that scenario:

    resetCleanupScheduleForTests()

    // Unfortunately, Jest fake timers don't mock out Date.now, so we fake
    // that out in parallel to Jest useFakeTimers
    let fakeNow = Date.now()
    jest.useFakeTimers()
    jest.spyOn(Date, "now").mockImplementation(() => fakeNow)

    const store = mobx.observable({ count: 0 })

    // Track whether the count is observed
    let countIsObserved = false
    mobx.onBecomeObserved(store, "count", () => (countIsObserved = true))
    mobx.onBecomeUnobserved(store, "count", () => (countIsObserved = false))

    const TestComponent1 = () => useObserver(() => <div>{store.count}</div>)

    // We're going to render directly using ReactDOM, not react-testing-library, because we want
    // to be able to get in and do nasty things before everything (including useEffects) have completed,
    // and react-testing-library waits for all that, using act().
    const rootNode = document.createElement("div")
    ReactDOM.render(
        <React.StrictMode>
            <TestComponent1 />
        </React.StrictMode>,
        rootNode
    )

    // We need to trigger our cleanup timer to run. We don't want
    // to allow Jest's effects to run, however: we want to simulate the
    // case where the component is rendered, then the reaction gets cleaned up,
    // and _then_ the component commits.

    // Force everything to be disposed.
    const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS)
    fakeNow += skip
    forceCleanupTimerToRunNowForTests()

    // The reaction should have been cleaned up.
    expect(countIsObserved).toBeFalsy()

    // Whilst nobody's looking, change the observable value
    store.count = 42

    // Now allow the useEffect calls to run to completion,
    // re-awakening the component.
    jest.advanceTimersByTime(500)
    act(() => {
        // no-op, but triggers effect flushing
    })

    // count should be observed once more.
    expect(countIsObserved).toBeTruthy()
    // and the component should have rendered enough to
    // show the latest value, which was set whilst it
    // wasn't even looking.
    expect(rootNode.textContent).toContain("42")
})