// Phase 3 of the video-playback coordination — pin the per-scope state
// isolation that fixes the cross-screen synchrony bug and the screen-blur
// cleanup that fixes the navigate-and-still-playing bug.
//
// Pinned contracts:
//   1. `useVideo()` outside `<PlaybackScope>` throws — the silent fall-
//      back the old VideoProvider had is gone by design.
//   2. Two sibling scopes have independent `currentVideoId` — calling
//      `playVideo(P)` in scope A does not flip scope B (the bug fix).
//   3. Cross-scope arbiter handover: claiming from scope B resets scope
//      A's `currentVideoId` to null. Defense in depth even when both
//      scopes stay focused (e.g. modal layered over feed before the
//      modal scope claims).
//   4. Screen blur runs the registered players' `pause()` and clears
//      `currentVideoId` to null. Does NOT auto-resume on re-focus.
//
// Mock surface (carried forward from §13.5 of the plan):
//   - `expo-router` exposes a controllable `useFocusEffect`. We capture
//     each scope's cleanup so the test can invoke it directly.
//   - `expo-video` exposes a stub `useVideoPlayer` returning a player
//     with the property surface useScopedPlayer touches; `<VideoView>`
//     is unused here because the tests render the hook, not the view.
//   - `expo` exposes a controllable `useEvent` returning a fixed
//     initial value (the hook reads `playingChange`).

import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'

// ─── expo-router: controllable useFocusEffect ───────────────────────
// Each PlaybackScope mounts one useFocusEffect with a callback that
// returns its blur-cleanup. The mock immediately invokes the callback
// (simulating focus) and stores the cleanup so the test can fire blur
// on demand. Variables referenced inside `jest.mock` factories must be
// prefixed `mock*` (jest hoist-safety carve-out).
type MockStubPlayer = {
  play: jest.Mock
  pause: jest.Mock
  replaceAsync: jest.Mock<Promise<void>, [unknown]>
  loop: boolean
  muted: boolean
  audioMixingMode: string
  staysActiveInBackground: boolean
  preservesPitch: boolean
  playing: boolean
}

const mockBlurCleanups: Array<() => void> = []
const mockCreatedPlayers: MockStubPlayer[] = []

jest.mock('expo-router', () => ({
  useFocusEffect: (cb: () => void | (() => void)) => {
    const React = require('react') as typeof import('react')
    React.useEffect(() => {
      const cleanup = cb()
      const idx = mockBlurCleanups.length
      mockBlurCleanups.push(typeof cleanup === 'function' ? cleanup : () => {})
      return () => {
        // React fires this on unmount or before re-running. Replace the
        // slot with a no-op so triggerAllBlurs doesn't double-invoke.
        mockBlurCleanups[idx] = () => {}
      }
    }, [cb])
  },
}))

jest.mock('expo-video', () => ({
  useVideoPlayer: (_source: unknown, setup?: (p: MockStubPlayer) => void) => {
    const React = require('react') as typeof import('react')
    const ref = React.useRef<MockStubPlayer | null>(null)
    if (!ref.current) {
      const player: MockStubPlayer = {
        play: jest.fn(),
        pause: jest.fn(),
        replaceAsync: jest.fn<Promise<void>, [unknown]>(async (_s) => undefined),
        loop: false,
        muted: false,
        audioMixingMode: 'mixWithOthers',
        staysActiveInBackground: false,
        preservesPitch: true,
        playing: false,
      }
      ref.current = player
      mockCreatedPlayers.push(player)
      try {
        setup?.(player)
      } catch {}
    }
    return ref.current
  },
}))

jest.mock('expo', () => ({
  useEvent: (_player: unknown, _event: string, initial: { isPlaying: boolean }) => initial,
}))

function triggerAllBlurs() {
  for (const fn of mockBlurCleanups) fn()
  mockBlurCleanups.length = 0
}

// Lazy-load the production modules so the jest.mock factories above are
// in effect when the modules' imports resolve.
type ScopeModule = typeof import('../src/hooks/playback/PlaybackScope')
type HookModule = typeof import('../src/hooks/playback/useScopedPlayer')
type ArbiterModule = typeof import('../src/lib/playback/arbiter')

let PlaybackScope: ScopeModule['PlaybackScope']
let useVideo: ScopeModule['useVideo']
let useScopedPlayer: HookModule['useScopedPlayer']
let __resetArbiter: ArbiterModule['__resetPlaybackArbiterForTests']

beforeAll(() => {
  ;({ PlaybackScope, useVideo } = require('../src/hooks/playback/PlaybackScope'))
  ;({ useScopedPlayer } = require('../src/hooks/playback/useScopedPlayer'))
  ;({
    __resetPlaybackArbiterForTests: __resetArbiter,
  } = require('../src/lib/playback/arbiter'))
})

beforeEach(() => {
  mockBlurCleanups.length = 0
  mockCreatedPlayers.length = 0
  __resetArbiter()
})

// Probe component — consumes useVideo() and reports its current state
// out via a ref-style callback. Re-rendering on currentVideoId changes
// is what the React Navigation context would do for a real screen.
type Probe = {
  currentVideoId: string | null
  playVideo: (id: string | null) => void
}
function makeProbe(): {
  Component: React.FC
  read: () => Probe
} {
  let snapshot: Probe = { currentVideoId: null, playVideo: () => {} }
  const Component: React.FC = () => {
    const { currentVideoId, playVideo } = useVideo()
    snapshot = { currentVideoId, playVideo }
    return null
  }
  return { Component, read: () => snapshot }
}

// Player probe — drives useScopedPlayer so the scope has a registered
// player to pause on blur.
function makePlayerProbe(videoId: string) {
  const Component: React.FC = () => {
    useScopedPlayer({
      source: { uri: `https://example.test/${videoId}.mp4` },
      videoId,
      mode: 'feed',
    })
    return null
  }
  return { Component }
}

describe('useVideo — outside provider', () => {
  it('throws when called without a <PlaybackScope> ancestor', () => {
    const Bare: React.FC = () => {
      useVideo()
      return null
    }
    // Suppress React's error logs for this expected throw.
    const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
    expect(() =>
      act(() => {
        create(React.createElement(Bare))
      })
    ).toThrow(/PlaybackScope/)
    errSpy.mockRestore()
  })
})

describe('PlaybackScope — sibling isolation (§3.1 regression guard)', () => {
  it('keeps currentVideoId per-scope: playVideo in A does not flip B', () => {
    const a = makeProbe()
    const b = makeProbe()
    let root: ReactTestRenderer = null as unknown as ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(React.Fragment, null, [
          React.createElement(
            PlaybackScope,
            { name: 'home', key: 'a' },
            React.createElement(a.Component)
          ),
          React.createElement(
            PlaybackScope,
            { name: 'profile', key: 'b' },
            React.createElement(b.Component)
          ),
        ])
      )
    })

    // Same videoId in both — pre-fix this would wake every matching
    // VideoPlayer in the app.
    act(() => {
      a.read().playVideo('post-42')
    })

    expect(a.read().currentVideoId).toBe('post-42')
    expect(b.read().currentVideoId).toBeNull()

    // Tearing down silences the renderer warnings about act().
    act(() => root.unmount())
  })

  it('cross-scope arbiter handover resets the loser', () => {
    const a = makeProbe()
    const b = makeProbe()
    let root: ReactTestRenderer = null as unknown as ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(React.Fragment, null, [
          React.createElement(
            PlaybackScope,
            { name: 'home', key: 'a' },
            React.createElement(a.Component)
          ),
          React.createElement(
            PlaybackScope,
            { name: 'story', key: 'b' },
            React.createElement(b.Component)
          ),
        ])
      )
    })

    act(() => a.read().playVideo('feed-1'))
    expect(a.read().currentVideoId).toBe('feed-1')

    // Story scope claims — arbiter fires home's release synchronously.
    act(() => b.read().playVideo('story:seg-1'))
    expect(b.read().currentVideoId).toBe('story:seg-1')
    expect(a.read().currentVideoId).toBeNull()

    act(() => root.unmount())
  })
})

describe('PlaybackScope — focus blur cleanup (§3.2 regression guard)', () => {
  it('pauses registered players and clears currentVideoId on blur', () => {
    const probe = makeProbe()
    const player = makePlayerProbe('post-7')
    let root: ReactTestRenderer = null as unknown as ReactTestRenderer
    act(() => {
      root = create(
        React.createElement(
          PlaybackScope,
          { name: 'home' },
          React.createElement(React.Fragment, null, [
            React.createElement(probe.Component, { key: 'p' }),
            React.createElement(player.Component, { key: 'v' }),
          ])
        )
      )
    })

    act(() => probe.read().playVideo('post-7'))
    expect(probe.read().currentVideoId).toBe('post-7')
    expect(mockCreatedPlayers.length).toBe(1)
    const stub = mockCreatedPlayers[0]
    // useScopedPlayer's feed-mode setup auto-plays when audible; we don't
    // assert on play() count because that's covered by the hook's own
    // surface. What matters here is that pause runs on blur.
    stub.pause.mockClear()

    act(() => triggerAllBlurs())

    expect(stub.pause).toHaveBeenCalled()
    expect(probe.read().currentVideoId).toBeNull()

    act(() => root.unmount())
  })
})
