/**
 * Recycled pool of `VideoPlayer` instances keyed by
 * `messageId:assetId`. Tiles + the gallery acquire/release players;
 * when a slot's refcount drops to zero it goes back into the pool as
 * "idle" and gets reassigned to the next acquire via `replaceAsync`
 * instead of being torn down and recreated.
 *
 * Why a pool: per-tile `useVideoPlayer` lifecycle (spawn / release on
 * every mount / unmount) was the root of every nasty bug we hit —
 * `NativeSharedObjectNotFoundException` when a borrowed player got
 * released by its owner mid-use, audio leaking when the cleanup raced
 * a release, and Android's "one player per VideoView" constraint
 * making cross-component sharing fragile. By keeping a fixed-ish set
 * of native instances alive for the lifetime of the chat screen and
 * just swapping their sources, we sidestep all of that.
 *
 * The pool grows on demand (one entry per concurrently-visible tile)
 * and is collapsed on `clear()` — which the chat screen calls on
 * unmount.
 */
import {
  createVideoPlayer,
  setVideoCacheSizeAsync,
  VideoPlayer,
  VideoSource,
} from 'expo-video';
import {sentryLogs} from '@shared/utils';

/** Preload buffer for a tile in the chat list — just enough for an
 *  instant start when the user taps. */
const PRELOAD_BUFFER_SECONDS = 3;
/** Disk cache shared across every video the app plays / preloads. The
 *  default is 1 GB; we make it bigger for better offline-first experience.
 *  expo-video evicts on an LRU basis when the limit is
 *  hit. Set once before any `VideoPlayer` is created — module-load
 *  time satisfies that contract. */
const VIDEO_CACHE_BYTES = 10000 * 1024 * 1024;

// Fire-and-forget; failures are non-fatal (default 1 GB still applies).
setVideoCacheSizeAsync(VIDEO_CACHE_BYTES).catch(() => {});

interface Slot {
  player: VideoPlayer;
  /** `messageId:assetId` while assigned, `undefined` when idle. */
  key: string | undefined;
  /** URI currently loaded on the native player. Compared on re-acquire
   *  so we can skip `replaceAsync` (and its buffer reset / playback
   *  hiccup) when the new source resolves to the same URI as the one
   *  the slot already has. The most common trigger of that is the
   *  consumer's source memo getting a new object identity from an auth
   *  header refresh without the underlying URL changing. */
  sourceUri: string | undefined;
  /** Number of mounted consumers (tile + gallery may share). */
  refCount: number;
  /**
   * Marks a slot that was created on behalf of a one-shot consumer (the
   * gallery, when it opens a video that wasn't preloaded by a chat
   * tile). When `release()` drops the refcount to 0, the native player
   * is fully released and the slot is removed from the pool rather than
   * going idle. Disposable slots are also the reason step 3 of `acquire`
   * (reassign an unrelated idle slot via `replaceAsync`) is skipped:
   * binding a fresh `VideoView` to a slot whose last decoded frame is a
   * different video shows that old frame for the few hundred ms it
   * takes the new source to produce its first frame — i.e. the "flash
   * of the previous video" the user sees when scrubbing through the
   * gallery.
   */
  disposable: boolean;
}

const slots: Slot[] = [];

const keyOf = (messageId: string, assetId: number) => `${messageId}:${assetId}`;

const sourceUriOf = (source: VideoSource): string | undefined => {
  if (source == null) return undefined;
  if (typeof source === 'string') return source;
  if (typeof source === 'number') return undefined;
  if (typeof source === 'object' && 'uri' in source) {
    return source.uri ?? undefined;
  }
  return undefined;
};

/** Wraps every native call: the player can briefly be in a bad state
 *  while expo-video processes an async source swap, and we don't want
 *  the JS thread to throw on a transient native failure. */
const callSafely = (fn: () => void): void => {
  try {
    fn();
  } catch (error) {
    sentryLogs.error({
      message: 'Video pool player error',
      module: 'CHATS',
      component: 'VideoPoolPlayer:callSafely',
      data: {
        error: error instanceof Error ? error.message : String(error),
      },
    });
  }
};

const resetPlayer = (player: VideoPlayer): void => {
  callSafely(() => {
    player.pause();
    player.muted = true;
    player.volume = 0;
    player.currentTime = 0;
    // The gallery widens this on `isActive` to buffer aggressively
    // (and seed the disk cache) while the user is watching. Now that
    // we're returning the slot to "preload mode", shrink it back down.
    player.bufferOptions = {
      preferredForwardBufferDuration: PRELOAD_BUFFER_SECONDS,
    };
  });
};

const setupNewPlayer = (player: VideoPlayer): void => {
  callSafely(() => {
    player.muted = true;
    player.volume = 0;
    player.pause();
    player.bufferOptions = {
      preferredForwardBufferDuration: PRELOAD_BUFFER_SECONDS,
    };
  });
};

interface AcquireOptions {
  /**
   * When `true`, acquire promises to fully release this slot on
   * refcount-zero (`player.release()` + remove from the pool) instead
   * of leaving it idle. Also skips the "reassign an unrelated idle
   * slot via `replaceAsync`" branch of acquire so the consumer never
   * sees the previous video's last frame flash through while the new
   * source loads. The gallery uses this for videos that weren't
   * preloaded by a chat tile; the tile itself acquires non-disposable
   * so its slot persists across tile recycling. Set on the slot at
   * creation time and never demoted — an existing slot reused via key
   * or URI match keeps whatever flag it was born with.
   */
  disposable?: boolean;
}

export const VideoPlayerPool = {
  /**
   * Get the player assigned to `(messageId, assetId)`, or assign a
   * fresh / idle slot to it and seed the source via `replaceAsync`.
   * Increments the slot's refcount.
   */
  // eslint-disable-next-line max-params
  acquire(
    messageId: string,
    assetId: number,
    source: VideoSource,
    options?: AcquireOptions,
  ): VideoPlayer {
    const key = keyOf(messageId, assetId);
    const sourceUri = sourceUriOf(source);
    const disposable = options?.disposable ?? false;

    const existing = slots.find(s => s.key === key);
    if (existing) {
      existing.refCount += 1;
      return existing.player;
    }

    // Prefer an idle slot that already has this URI loaded — happens
    // every time the consumer's source memo changes identity without
    // the underlying URL changing (token refresh, header rebuild). No
    // `replaceAsync` means no buffer reset and no play / pause / play
    // hiccup the user would otherwise see. Safe for `disposable`
    // acquires too: the slot already has the right frame on screen, so
    // there's no flash to avoid.
    if (sourceUri) {
      const idleMatchingUri = slots.find(
        s => s.refCount === 0 && s.sourceUri === sourceUri,
      );
      if (idleMatchingUri) {
        idleMatchingUri.key = key;
        idleMatchingUri.refCount = 1;
        return idleMatchingUri.player;
      }
    }

    // Reassign any idle slot via `replaceAsync`. Skipped for
    // disposable acquires — the previous video's last frame remains
    // in the texture while the new source loads (a few hundred ms on
    // remote URLs), and that's the "flash" the user complained about
    // when opening gallery videos that weren't preloaded. Falling
    // through creates a brand-new player whose surface starts black
    // and is fully covered by our thumbnail placeholder until the
    // first frame paints.
    if (!disposable) {
      const idle = slots.find(s => s.refCount === 0);
      if (idle) {
        idle.key = key;
        idle.refCount = 1;
        idle.sourceUri = sourceUri;
        callSafely(() => {
          idle.player.replaceAsync(source).catch(() => {});
        });
        return idle.player;
      }
    }

    const player = createVideoPlayer(source);
    setupNewPlayer(player);
    slots.push({player, key, sourceUri, refCount: 1, disposable});
    return player;
  },

  /**
   * Decrement refcount; when it hits zero the slot goes idle
   * (preserved for re-acquire) — or, if the slot was created
   * disposable, the native player is released and the slot is dropped
   * from the pool entirely.
   */
  release(messageId: string, assetId: number): void {
    const key = keyOf(messageId, assetId);
    const idx = slots.findIndex(s => s.key === key);
    if (idx === -1) return;
    const slot = slots[idx];
    slot.refCount -= 1;
    if (slot.refCount > 0) return;
    slot.refCount = 0;
    slot.key = undefined;
    if (slot.disposable) {
      // Disposable slots — the gallery's one-shot acquisitions — have
      // no second consumer waiting in the wings, so destroying the
      // native instance here keeps the pool tight: chat tile slots
      // stay live, gallery-only slots evaporate with the gallery.
      callSafely(() => slot.player.release());
      slots.splice(idx, 1);
      return;
    }
    resetPlayer(slot.player);
  },

  /** Release every native player. Call on chat screen unmount or logout. */
  clear(): void {
    for (const slot of slots) {
      callSafely(() => slot.player.release());
    }
    slots.length = 0;
  },
};
