import { useFocusEffect } from 'expo-router'
import type { VideoPlayer } from 'expo-video'
import {
  createContext,
  type PropsWithChildren,
  useCallback,
  useContext,
  useId,
  useMemo,
  useRef,
  useState,
} from 'react'
import { playbackArbiter } from 'src/lib/playback/arbiter'

// Per-screen video coordinator. Replaces the earlier single global
// VideoProvider with a per-scope one.
//
// Two scopes with the same `videoId` no longer collide because
// `currentVideoId` lives per-scope, fixing the "tap-to-play in Home also
// plays the same post in pushed Profile" bug structurally. The shared
// state across scopes is the global `playbackArbiter` audio slot — at
// most one scope's audible video plays at a time.
//
// Public surface intentionally mirrors the old `useVideo()` so existing
// consumers (TimelineList, FeedPost) migrate with a one-line import.

export type RegisteredPlayer = {
  player: VideoPlayer
  // True when this player is the audible holder for its scope and should
  // pin background audio when the screen blurs (it never does, since blur
  // pauses all players in the scope and yields the slot — but the flag
  // lets future hardening, e.g. now-playing metadata, key off it).
  audible: boolean
}

type ScopeCtx = {
  // Stable per-mount id; useScopedPlayer needs this to claim the arbiter
  // on behalf of the scope without coupling to React state ordering.
  scopeId: string
  currentVideoId: string | null
  playVideo: (id: string | null) => void
  // Feed surfaces call this with the IDs in/near the viewport so off-screen
  // video players can detach their native buffers. When null or empty, all
  // videos are considered active (detail views, single-video screens).
  setPreloadWindow: (ids: string[] | null) => void
  isActive: (id: string) => boolean
  // Registry handles for useScopedPlayer. The scope owns the registry so
  // its `useFocusEffect` cleanup can pause every owned player without
  // coordinating with each consumer individually.
  registerPlayer: (videoId: string, entry: RegisteredPlayer) => () => void
}

// `Symbol.for` so an accidental dual-React or duplicate-module bundling
// doesn't silently produce two distinct "no provider" sentinels — both
// modules see the same Symbol and either find a provider above or share
// the same throw.
const NO_PROVIDER = Symbol.for('Halflight.PlaybackScope.NoProvider')

const PlaybackContext = createContext<ScopeCtx | typeof NO_PROVIDER>(NO_PROVIDER)

type Props = PropsWithChildren<{
  // Used only for debugging — the React `useId()` is what makes the scope
  // identity unique. `name` shows up in arbiter telemetry / logs if we
  // ever add them, and helps when grepping for which screen owns a scope.
  name: string
}>

export function PlaybackScope({ name, children }: Props) {
  const reactId = useId()
  // The compound id is stable across renders (useId is stable) and unique
  // across mounts (two simultaneous PlaybackScopes always have different
  // useId values). `name` is informational; do not rely on it for
  // uniqueness.
  const scopeId = `${reactId}:${name}`

  const [currentVideoId, setCurrentVideoId] = useState<string | null>(null)
  const [preloadIds, setPreloadIds] = useState<ReadonlySet<string> | null>(null)
  const preloadRef = useRef(preloadIds)
  preloadRef.current = preloadIds

  // videoId -> registered player. Map allows two players that briefly
  // share a videoId (FlashList recycle window) to coexist; the second
  // registration overwrites the first, and the first's unregister becomes
  // a no-op (it only removes if the entry still matches).
  const registry = useRef(new Map<string, RegisteredPlayer>())

  const playVideo = useCallback(
    (id: string | null) => {
      if (id) {
        // Re-claiming from this same scope is harmless — the arbiter
        // will not call our own release callback. Claiming from a
        // different scope synchronously triggers that scope's release,
        // which resets its `currentVideoId` to null and pauses its
        // players. See arbiter.ts.
        playbackArbiter.claim(scopeId, () => {
          setCurrentVideoId(null)
        })
      } else {
        playbackArbiter.yield(scopeId)
      }
      setCurrentVideoId(id)
    },
    [scopeId]
  )

  const setPreloadWindow = useCallback((ids: string[] | null) => {
    if (!ids || ids.length === 0) {
      if (preloadRef.current !== null) setPreloadIds(null)
      return
    }
    const next = new Set(ids)
    const prev = preloadRef.current
    if (prev && prev.size === next.size) {
      let same = true
      for (const id of next) {
        if (!prev.has(id)) {
          same = false
          break
        }
      }
      if (same) return
    }
    setPreloadIds(next)
  }, [])

  const isActive = useCallback(
    (id: string) => {
      const set = preloadIds
      return set === null ? true : set.has(id)
    },
    [preloadIds]
  )

  const registerPlayer = useCallback((videoId: string, entry: RegisteredPlayer) => {
    registry.current.set(videoId, entry)
    return () => {
      if (registry.current.get(videoId) === entry) {
        registry.current.delete(videoId)
      }
    }
  }, [])

  // Pause every player owned by this scope when the screen blurs. Covers
  // stack push, tab switch, and modal presentation — but not app
  // background, which is handled per-player via `staysActiveInBackground`
  // on the audible holder. Deliberately does *not* auto-resume on
  // re-focus: the user lost their place when they navigated away;
  // silently bringing audio back when they pop is jarring.
  useFocusEffect(
    useCallback(
      () => () => {
        for (const entry of registry.current.values()) {
          try {
            entry.player.pause()
          } catch {
            // Swallow: a freshly-released player throws on pause(). The
            // useVideoPlayer hook auto-releases on unmount, so a few
            // frames after blur this entry may already be invalid.
          }
        }
        playbackArbiter.yield(scopeId)
        setCurrentVideoId(null)
      },
      [scopeId]
    )
  )

  const value = useMemo<ScopeCtx>(
    () => ({
      scopeId,
      currentVideoId,
      playVideo,
      setPreloadWindow,
      isActive,
      registerPlayer,
    }),
    [scopeId, currentVideoId, playVideo, setPreloadWindow, isActive, registerPlayer]
  )

  return <PlaybackContext value={value}>{children}</PlaybackContext>
}

// Internal — useScopedPlayer needs the full context (scopeId, registry).
export function usePlaybackScope(): ScopeCtx {
  const ctx = useContext(PlaybackContext)
  if (ctx === NO_PROVIDER) {
    throw new Error(
      'usePlaybackScope() / useVideo() called outside of <PlaybackScope>. ' +
        'Wrap the screen body in <PlaybackScope name="…">.'
    )
  }
  return ctx
}

// Public — same shape as the old `useVideo()` for migration ergonomics.
// Hides `scopeId` and `registerPlayer`, which are only useScopedPlayer's
// concern.
export function useVideo() {
  const { currentVideoId, playVideo, setPreloadWindow, isActive } = usePlaybackScope()
  return { currentVideoId, playVideo, setPreloadWindow, isActive }
}
