// Phase 3 of the video-playback coordination — pin the audio-slot
// contract that the rest of the playback system relies on.
//
// The arbiter is the only piece of cross-scope state in the playback
// layer, and it has to behave precisely under the stack-push handover:
// claim-from-a-different-scope must synchronously drop the prior holder
// before installing the new one, otherwise two scopes can each believe
// they own the slot for a frame and both attempt to play audio.
//
// Pinned contracts:
//   1. Fresh arbiter has no holder.
//   2. Re-claim from a *different* scope releases the prior holder
//      exactly once before installing the new one.
//   3. Re-claim from the *same* scope does NOT fire its own release —
//      a scope yielding to itself uses `yield`, not re-`claim`.
//   4. `yield` from a non-holder is a no-op (the prior holder still
//      owns the slot).
//   5. A buggy release callback that throws does not wedge the arbiter
//      for subsequent claims.

import {
  __resetPlaybackArbiterForTests,
  playbackArbiter,
} from '../src/lib/playback/arbiter'

beforeEach(() => {
  __resetPlaybackArbiterForTests()
})

describe('PlaybackArbiter — initial state', () => {
  it('reports no holder after reset', () => {
    expect(playbackArbiter.has('A')).toBe(false)
    expect(playbackArbiter.has('B')).toBe(false)
  })
})

describe('PlaybackArbiter — claim handover', () => {
  it('records the first claim without firing any release', () => {
    const releaseA = jest.fn()
    playbackArbiter.claim('A', releaseA)
    expect(playbackArbiter.has('A')).toBe(true)
    expect(releaseA).not.toHaveBeenCalled()
  })

  it('releases the prior holder exactly once on cross-scope claim', () => {
    const releaseA = jest.fn()
    const releaseB = jest.fn()
    playbackArbiter.claim('A', releaseA)
    playbackArbiter.claim('B', releaseB)

    expect(releaseA).toHaveBeenCalledTimes(1)
    expect(releaseB).not.toHaveBeenCalled()
    expect(playbackArbiter.has('B')).toBe(true)
    expect(playbackArbiter.has('A')).toBe(false)
  })

  it('does not fire the same scope’s release on re-claim', () => {
    // A scope choosing a new audible video calls `claim` again with the
    // same scopeId — its own release callback must NOT fire, otherwise
    // the new claim would immediately reset the scope's currentVideoId
    // it just installed (silently breaking tap-to-switch within a
    // single screen).
    const releaseA1 = jest.fn()
    const releaseA2 = jest.fn()
    playbackArbiter.claim('A', releaseA1)
    playbackArbiter.claim('A', releaseA2)
    expect(releaseA1).not.toHaveBeenCalled()
    expect(releaseA2).not.toHaveBeenCalled()
    expect(playbackArbiter.has('A')).toBe(true)
  })

  it('survives a throwing release callback', () => {
    const boom = jest.fn(() => {
      throw new Error('release threw')
    })
    const releaseB = jest.fn()
    playbackArbiter.claim('A', boom)
    expect(() => playbackArbiter.claim('B', releaseB)).not.toThrow()
    expect(boom).toHaveBeenCalledTimes(1)
    expect(playbackArbiter.has('B')).toBe(true)
  })
})

describe('PlaybackArbiter — yield', () => {
  it('clears the slot when called by the current holder', () => {
    const release = jest.fn()
    playbackArbiter.claim('A', release)
    playbackArbiter.yield('A')
    expect(playbackArbiter.has('A')).toBe(false)
    // yield is a self-invoked drop — it must not fire the holder's own
    // release (release is for being kicked out, yield is for stepping
    // down voluntarily).
    expect(release).not.toHaveBeenCalled()
  })

  it('is a no-op when called by a non-holder', () => {
    const releaseA = jest.fn()
    playbackArbiter.claim('A', releaseA)
    playbackArbiter.yield('B')
    // A still holds; releaseA never fired.
    expect(playbackArbiter.has('A')).toBe(true)
    expect(releaseA).not.toHaveBeenCalled()
  })

  it('is a no-op on an empty arbiter', () => {
    expect(() => playbackArbiter.yield('A')).not.toThrow()
    expect(playbackArbiter.has('A')).toBe(false)
  })
})
