import { cleanup, fireEvent, render, screen, act } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { StoryContentArea } from '@/Components/StoryContentArea';
import { useStoryAudio } from '@/hooks/useStoryAudio';
import type { ReaderStory, StoryParagraph } from '@/types/story-reader';

vi.mock('@/Components/Transition/FadePresence', () => ({
  default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

const START_QUIZ_COLORS = {
  background: '#000',
  hover: '#000',
  active: '#000',
  disabled: '#000',
  text: '#fff',
  textHover: '#fff',
  textActive: '#fff',
  textDisabled: '#fff',
};

function buildParagraphs(): StoryParagraph[] {
  return [1, 2].map((paragraphNumber) => ({
    id: paragraphNumber,
    paragraph_number: paragraphNumber,
    content: `Paragraph ${paragraphNumber}.`,
    sentences: [1, 2].map((sentenceNumber) => ({
      id: paragraphNumber * 10 + sentenceNumber,
      sentence_number: sentenceNumber,
      content: `Sentence p${paragraphNumber}s${sentenceNumber}.`,
      audio_url: `/storage/audio/v1-p${paragraphNumber}-s${sentenceNumber}.mp4`,
    })),
  }));
}

const story: ReaderStory = {
  id: 1,
  slug: 'a-stubborn-kitty',
  title: 'A Stubborn Kitty',
  synopsis: '',
  media: [],
  color_theme: 'default',
};

/** Mirrors how Story.tsx wires the hook to the reader. */
function StoryReaderHarness() {
  const [currentParagraph, setCurrentParagraph] = useState(0);
  const [isQuizMode, setIsQuizMode] = useState(false);
  const paragraphs = buildParagraphs();

  const {
    hasStoryCompleted,
    currentlyPlayingSentence,
    togglePlayPause,
    handleParagraphNavSelect,
    handleSliderStart,
    handleProgressChange,
    handleSliderEnd,
    sentencePlayback,
  } = useStoryAudio({
    story,
    paragraphs,
    currentVersionData: { id: 1, version_number: 1, pages: [] },
    currentVersion: 1,
    totalParagraphs: paragraphs.length,
    currentParagraph,
    setCurrentParagraph,
    setIsQuizMode,
  });

  const paragraph = paragraphs[currentParagraph];

  return (
    <div>
      <button onClick={togglePlayPause}>toggle-play</button>
      <button onClick={() => handleParagraphNavSelect(currentParagraph + 1)}>next-paragraph</button>
      <button
        onClick={() => {
          handleSliderStart();
          handleProgressChange([100]);
          handleSliderEnd();
        }}
      >
        scrub-to-end
      </button>
      <StoryContentArea
        isQuizMode={isQuizMode}
        canStartQuiz={hasStoryCompleted}
        hasQuizProgress={false}
        paragraphData={{
          content: paragraph?.content ?? '',
          sentences: (paragraph?.sentences ?? []).map((sentence) => sentence.content),
          isSingleSentence: false,
          maxLines: 4,
          backgroundImage: null,
          textWidth: null,
          textOffsetX: null,
        }}
        currentParagraph={currentParagraph}
        currentVersion={1}
        currentlyPlayingSentence={currentlyPlayingSentence}
        startQuizColors={START_QUIZ_COLORS}
        onStartQuiz={() => {}}
        sentencePlayback={sentencePlayback}
        quizPrefacePart1={null}
        quizPrefacePart2={null}
      />
    </div>
  );
}

const realSetTimeout = globalThis.setTimeout;

let originalPlay: HTMLMediaElement['play'];
let originalPause: HTMLMediaElement['pause'];
let pendingPlays: Array<(reason: DOMException) => void> = [];
let unhandledRejections: unknown[] = [];
let savedListeners: Array<(...args: unknown[]) => void> = [];

function interruptPendingPlays(message: string) {
  const rejectors = pendingPlays;
  pendingPlays = [];
  rejectors.forEach((reject) => reject(new DOMException(message, 'AbortError')));
}

beforeEach(() => {
  unhandledRejections = [];
  savedListeners = process.listeners('unhandledRejection') as Array<(...args: unknown[]) => void>;
  process.removeAllListeners('unhandledRejection');
  process.on('unhandledRejection', (reason) => {
    unhandledRejections.push(reason);
  });

  vi.useFakeTimers({ shouldAdvanceTime: true });

  originalPlay = HTMLMediaElement.prototype.play;
  originalPause = HTMLMediaElement.prototype.pause;

  // Reproduces the browser contract behind READINGFUL-0V-G: play() resolves
  // only once playback has begun, and a pause() arriving while it is still
  // pending rejects it with AbortError. jsdom implements neither method, and
  // the sibling suite stubs play() as Promise.resolve(), which can never
  // reproduce the failure.
  //
  // Patched by assignment rather than with `vi.spyOn`: a vitest spy attaches
  // its own handler to the returned promise to populate `mock.settledResults`,
  // which marks the rejection handled and hides the very defect under test.
  HTMLMediaElement.prototype.play = function (this: HTMLMediaElement) {
    return new Promise<void>((_resolve, reject) => {
      pendingPlays.push(reject);
    });
  };

  HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement) {
    interruptPendingPlays('The play() request was interrupted by a call to pause().');
  };
});

afterEach(() => {
  cleanup();
  pendingPlays = [];
  HTMLMediaElement.prototype.play = originalPlay;
  HTMLMediaElement.prototype.pause = originalPause;
  vi.useRealTimers();
  vi.restoreAllMocks();
  process.removeAllListeners('unhandledRejection');
  savedListeners.forEach((listener) => process.on('unhandledRejection', listener));
});

/**
 * Node emits `unhandledRejection` on a real macrotask tick. `vi.useFakeTimers`
 * replaces the global `setTimeout`, so waiting on the faked one never yields
 * that tick and every rejection would look handled.
 */
async function settle() {
  await act(async () => {
    for (let tick = 0; tick < 3; tick++) {
      await new Promise((resolve) => realSetTimeout(resolve, 0));
    }
  });
}

function click(text: string) {
  act(() => {
    fireEvent.click(screen.getByText(text, { exact: false }));
  });
}

describe('an interrupted play() on the story reader (READINGFUL-0V-G)', () => {
  it('does not go unhandled when a sentence click cuts off the reading', async () => {
    render(<StoryReaderHarness />);

    click('toggle-play'); // clip still buffering, play() promise pending
    click('Sentence p1s2.'); // pauses the shared element mid-buffer

    await settle();

    expect(unhandledRejections).toEqual([]);
  });

  it('does not go unhandled when the student pauses before playback begins', async () => {
    render(<StoryReaderHarness />);

    click('toggle-play');
    click('toggle-play');

    await settle();

    expect(unhandledRejections).toEqual([]);
  });

  it('does not go unhandled when one sentence click interrupts another', async () => {
    render(<StoryReaderHarness />);

    click('Sentence p1s1.');
    click('Sentence p1s2.');

    await settle();

    expect(unhandledRejections).toEqual([]);
  });

  it('does not go unhandled when paragraph navigation interrupts playback', async () => {
    render(<StoryReaderHarness />);

    click('toggle-play');
    click('next-paragraph');

    await settle();

    expect(unhandledRejections).toEqual([]);
  });

  it('does not go unhandled when scrubbing interrupts playback', async () => {
    render(<StoryReaderHarness />);

    click('toggle-play');
    click('scrub-to-end');

    await settle();

    expect(unhandledRejections).toEqual([]);
  });
});
