// `useAsyncRefresh` lifecycle tests. The hook drives `Mastodon-Async-Refresh`
// polling for the post screen (P5): given a parsed spec and an `onPoll`
// callback, it schedules `onPoll` every `retrySeconds * 1000` ms while the
// spec is non-null, and fires `onComplete` exactly once on the non-null →
// null transition (the canonical completion signal — see Halflight audit M6).
//
// The lifecycle is intentionally narrow: `onComplete` only fires on the
// non-null → null edge, not on id changes, not on unmount, not on the
// `maxDurationMs` ceiling. The cap is a "stop polling" guard against a
// runaway server, not a fake completion — the spec is still considered
// live from the server's perspective; we just stop chasing it.

import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import {
  type AsyncRefreshSpec,
  useAsyncRefresh,
} from 'src/lib/asyncRefresh'

type ProbeProps = {
  spec: AsyncRefreshSpec | null
  onPoll: () => void
  onComplete?: () => void
  maxDurationMs?: number
}

function Probe({ spec, onPoll, onComplete, maxDurationMs }: ProbeProps) {
  useAsyncRefresh({ spec, onPoll, onComplete, maxDurationMs })
  return null
}

function spec(
  id: string,
  retrySeconds = 3,
  resultCount: number | null = null
): AsyncRefreshSpec {
  return { id, retrySeconds, resultCount }
}

describe('useAsyncRefresh', () => {
  beforeEach(() => {
    jest.useFakeTimers()
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('schedules nothing when `spec` starts null', () => {
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: null, onPoll, onComplete })
      )
    })
    act(() => {
      jest.advanceTimersByTime(60_000)
    })
    expect(onPoll).not.toHaveBeenCalled()
    expect(onComplete).not.toHaveBeenCalled()
    act(() => {
      root!.unmount()
    })
  })

  it('fires `onPoll` after `retrySeconds * 1000` ms when `spec` becomes non-null', () => {
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: null, onPoll, onComplete })
      )
    })
    act(() => {
      root!.update(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll, onComplete })
      )
    })
    act(() => {
      jest.advanceTimersByTime(2_999)
    })
    expect(onPoll).not.toHaveBeenCalled()
    act(() => {
      jest.advanceTimersByTime(1)
    })
    expect(onPoll).toHaveBeenCalledTimes(1)
    expect(onComplete).not.toHaveBeenCalled()
    act(() => {
      root!.unmount()
    })
  })

  it('reschedules `onPoll` when `result_count` climbs (still polling mid-walk)', () => {
    // The server's running phase emits a climbing `result_count` as it
    // ingests rows. Each climb re-renders the post screen with a fresh
    // spec; the hook must reschedule the next poll instead of waiting on
    // a stale timer.
    const onPoll = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: spec('ctx:1', 3, 0), onPoll })
      )
    })
    // Halfway through the first interval, server reports `result_count=5`.
    act(() => {
      jest.advanceTimersByTime(1_500)
    })
    act(() => {
      root!.update(
        React.createElement(Probe, { spec: spec('ctx:1', 3, 5), onPoll })
      )
    })
    // The original 3 s timer was cleared; the new one starts now. A full
    // 3 s further on, exactly one `onPoll` should have fired.
    act(() => {
      jest.advanceTimersByTime(2_999)
    })
    expect(onPoll).not.toHaveBeenCalled()
    act(() => {
      jest.advanceTimersByTime(1)
    })
    expect(onPoll).toHaveBeenCalledTimes(1)
    act(() => {
      root!.unmount()
    })
  })

  it('clears the timer and fires `onComplete` exactly once on non-null → null', () => {
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll, onComplete })
      )
    })
    act(() => {
      jest.advanceTimersByTime(1_500)
    })
    act(() => {
      root!.update(
        React.createElement(Probe, { spec: null, onPoll, onComplete })
      )
    })
    expect(onComplete).toHaveBeenCalledTimes(1)
    // The pending 3 s timer was cleared on the spec → null transition.
    act(() => {
      jest.advanceTimersByTime(60_000)
    })
    expect(onPoll).not.toHaveBeenCalled()
    expect(onComplete).toHaveBeenCalledTimes(1)
    act(() => {
      root!.unmount()
    })
  })

  it('keeps polling at the new `retrySeconds` when `id` flips to a different refresh', () => {
    // Edge case: a remote anchor's job finished and a new federated walk
    // started in the same screen mount (rare in practice — the cooldown
    // gates this server-side — but the hook must not lock onto the old
    // cadence). `onComplete` is reserved for the non-null → null edge per
    // the M6 contract, so it does NOT fire on an id change.
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll, onComplete })
      )
    })
    act(() => {
      jest.advanceTimersByTime(1_000)
    })
    act(() => {
      root!.update(
        React.createElement(Probe, { spec: spec('ctx:2', 5), onPoll, onComplete })
      )
    })
    // Old 3 s timer is gone; the new 5 s timer drives the next poll.
    act(() => {
      jest.advanceTimersByTime(4_999)
    })
    expect(onPoll).not.toHaveBeenCalled()
    act(() => {
      jest.advanceTimersByTime(1)
    })
    expect(onPoll).toHaveBeenCalledTimes(1)
    expect(onComplete).not.toHaveBeenCalled()
    act(() => {
      root!.unmount()
    })
  })

  it('stops polling past `maxDurationMs` without firing `onComplete`', () => {
    // Runaway-server guard. The spec is still live from the server's
    // perspective — we just stop chasing it. `onComplete` is reserved for
    // the genuine non-null → null edge, so it must NOT fire on the cap.
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, {
          spec: spec('ctx:1', 3, 0),
          onPoll,
          onComplete,
          maxDurationMs: 5_000,
        })
      )
    })
    // Burn through the cap. The first scheduled poll fires at t=3 000 ms;
    // we then bump `result_count` so the effect re-runs. The re-run
    // computes `elapsed > maxDurationMs` and bails early, skipping the
    // setTimeout entirely.
    act(() => {
      jest.advanceTimersByTime(3_000)
    })
    expect(onPoll).toHaveBeenCalledTimes(1)
    act(() => {
      jest.advanceTimersByTime(3_000)
    })
    act(() => {
      root!.update(
        React.createElement(Probe, {
          spec: spec('ctx:1', 3, 1),
          onPoll,
          onComplete,
          maxDurationMs: 5_000,
        })
      )
    })
    // Nothing new is scheduled — even another full cap-window passes idle.
    act(() => {
      jest.advanceTimersByTime(60_000)
    })
    expect(onPoll).toHaveBeenCalledTimes(1)
    expect(onComplete).not.toHaveBeenCalled()
    act(() => {
      root!.unmount()
    })
  })

  it('clears the timer on unmount without firing `onComplete`', () => {
    // Unmount is not a completion signal — the next mount can pick up the
    // server-side state cleanly via a fresh `/context` fetch. Firing
    // `onComplete` synthetically on unmount would invalidate caches a
    // user already navigated away from.
    const onPoll = jest.fn()
    const onComplete = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll, onComplete })
      )
    })
    act(() => {
      jest.advanceTimersByTime(1_500)
    })
    act(() => {
      root!.unmount()
    })
    act(() => {
      jest.advanceTimersByTime(60_000)
    })
    expect(onPoll).not.toHaveBeenCalled()
    expect(onComplete).not.toHaveBeenCalled()
  })

  it('reads the latest `onPoll` reference at fire time (no useCallback required)', () => {
    // The hook stores `onPoll`/`onComplete` in a ref that's overwritten on
    // every render, so callers can pass inline closures without churning
    // the effect deps. Verify by swapping the callback mid-flight and
    // confirming the second one fires.
    const first = jest.fn()
    const second = jest.fn()
    let root: ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll: first })
      )
    })
    act(() => {
      jest.advanceTimersByTime(1_000)
    })
    act(() => {
      root!.update(
        React.createElement(Probe, { spec: spec('ctx:1', 3), onPoll: second })
      )
    })
    // Same spec id + result_count — the deps are stable, so the existing
    // timer is preserved. When it fires, the latest `onPoll` ref wins.
    act(() => {
      jest.advanceTimersByTime(2_000)
    })
    expect(first).not.toHaveBeenCalled()
    expect(second).toHaveBeenCalledTimes(1)
    act(() => {
      root!.unmount()
    })
  })
})
