/* @vitest-environment jsdom */

import { act, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';

import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
  BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
  BABY_OBJECT_MATCH_TEMPLATE_ID,
  BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { UseMocapInputResult } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell';

vi.mock('../ResolvedAssetImage', () => ({
  ResolvedAssetImage: ({
    src,
    alt,
    className,
    'data-testid': dataTestId,
  }: {
    src?: string | null;
    alt?: string;
    className?: string;
    'data-testid'?: string;
  }) =>
    src ? (
      <img
        src={src}
        alt={alt}
        className={className}
        data-testid={dataTestId}
      />
    ) : null,
}));

vi.mock('../../services/useMocapInput', () => ({
  useMocapInput: () => ({
    status: 'idle',
    latestCommand: null,
    rawPacketPreview: null,
    error: null,
  }),
}));

function createDraft(): BabyObjectMatchDraft {
  return {
    draftId: 'baby-object-draft-1',
    profileId: 'baby-object-profile-1',
    templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
    templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
    workTitle: '宝贝识物',
    workDescription: '苹果和香蕉识物分类',
    itemNames: ['苹果', '香蕉'],
    itemAssets: [
      {
        itemId: 'baby-object-item-1',
        itemName: '苹果',
        imageSrc: 'data:image/svg+xml;utf8,apple',
        assetObjectId: null,
        generationProvider: 'placeholder',
        prompt: '苹果',
      },
      {
        itemId: 'baby-object-item-2',
        itemName: '香蕉',
        imageSrc: 'data:image/svg+xml;utf8,banana',
        assetObjectId: null,
        generationProvider: 'placeholder',
        prompt: '香蕉',
      },
    ],
    visualPackage: null,
    themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
    publicationStatus: 'published',
    createdAt: '2026-05-11T00:00:00.000Z',
    updatedAt: '2026-05-11T00:00:00.000Z',
    publishedAt: '2026-05-11T00:00:00.000Z',
  };
}

function createVisualPackageDraft(): BabyObjectMatchDraft {
  return {
    ...createDraft(),
    visualPackage: {
      themePrompt: '果园主题',
      assets: [
        {
          assetId: 'baby-object-visual-background',
          assetKind: 'background',
          imageSrc: 'data:image/png;base64,background',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '背景',
        },
        {
          assetId: 'baby-object-visual-ui-frame',
          assetKind: 'ui-frame',
          imageSrc: 'data:image/png;base64,ui',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: 'UI',
        },
        {
          assetId: 'baby-object-visual-gift-box',
          assetKind: 'gift-box',
          imageSrc: 'data:image/png;base64,gift',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '礼盒',
        },
        {
          assetId: 'baby-object-visual-basket',
          assetKind: 'basket',
          imageSrc: 'data:image/png;base64,basket',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '篮子',
        },
        {
          assetId: 'baby-object-visual-smoke-puff',
          assetKind: 'smoke-puff',
          imageSrc: 'data:image/png;base64,smoke',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '烟雾',
        },
      ],
    },
  };
}

function createMocapInput(
  overrides: Partial<UseMocapInputResult> = {},
): UseMocapInputResult {
  return {
    status: 'connected',
    latestCommand: null,
    rawPacketPreview: null,
    error: null,
    ...overrides,
  };
}

function createRandomSequence(values: number[]) {
  let index = 0;
  return () => {
    const value = values[index] ?? values[values.length - 1] ?? 0;
    index += 1;
    return value;
  };
}

function dispatchPointerEvent(
  target: HTMLElement,
  type: string,
  options: {
    pointerId: number;
    button?: number;
    buttons?: number;
    clientX: number;
    clientY: number;
  },
) {
  const event = new Event(type, { bubbles: true, cancelable: true });
  Object.assign(event, options);
  target.dispatchEvent(event);
  return event;
}

function setStageRect(stage: HTMLElement) {
  Object.defineProperty(stage, 'getBoundingClientRect', {
    configurable: true,
    value: () => ({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 320,
      bottom: 240,
      width: 320,
      height: 240,
      toJSON: () => ({}),
    }),
  });
}

function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) {
  setStageRect(stage);

  act(() => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: button + 1,
      button,
      buttons: button === 2 ? 2 : 1,
      clientX: 160,
      clientY: 89,
    });
  });
  act(() => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: button + 1,
      button,
      buttons: button === 2 ? 2 : 1,
      clientX: targetX,
      clientY: 190,
    });
  });
  act(() => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: button + 1,
      button,
      buttons: 0,
      clientX: targetX,
      clientY: 190,
    });
  });
}

async function advanceRoundIntro() {
  await advanceInitialTargetPreview();
  await advanceGiftIntro();
}

async function advanceInitialTargetPreview() {
  await act(async () => {
    await vi.advanceTimersByTimeAsync(2000);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(720);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(1000);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(2000);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(720);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(1000);
  });
}

async function advanceGiftIntro() {
  await act(async () => {
    await vi.advanceTimersByTimeAsync(620);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(640);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(620);
  });
}

async function advanceFeedback() {
  await act(async () => {
    await vi.advanceTimersByTimeAsync(1200);
  });
}

test('shows the first gift item after gift and item animations', async () => {
  vi.useFakeTimers();
  render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0])}
    />,
  );

  expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
  expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
  expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');

  await advanceRoundIntro();

  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('previews both target items before the first gift box round', async () => {
  vi.useFakeTimers();
  render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0])}
    />,
  );

  expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
  expect(
    within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'),
  ).toBeTruthy();
  expect(screen.queryByLabelText('礼物盒')).toBeNull();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(2000);
  });

  expect(screen.getByTestId('baby-object-intro-item').className).toContain(
    'baby-object-runtime__intro-item--flying',
  );

  await act(async () => {
    await vi.advanceTimersByTimeAsync(720);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(1000);
  });

  expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
  expect(
    within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'),
  ).toBeTruthy();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(2000);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(720);
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(1000);
  });

  expect(screen.queryByTestId('baby-object-intro-item')).toBeNull();
  expect(screen.getByLabelText('礼物盒')).toBeTruthy();
  vi.useRealTimers();
});

test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
  vi.useFakeTimers();
  const { container } = render(
    <BabyObjectMatchRuntimeShell draft={createVisualPackageDraft()} />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  expect(stage.classList.contains('baby-object-runtime__stage--skinned')).toBe(
    true,
  );
  expect(
    screen
      .getByTestId('baby-object-background-image')
      .getAttribute('src'),
  ).toBe('data:image/png;base64,background');
  expect(
    stage.style.getPropertyValue('--baby-object-ui-frame-image'),
  ).toContain('ui');
  expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
    'smoke',
  );
  await advanceInitialTargetPreview();

  expect(screen.getByAltText('礼物盒')).toBeTruthy();
  expect(
    container.querySelector('.baby-object-runtime__basket-shell-image'),
  ).toBeTruthy();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(620);
  });

  expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
  vi.useRealTimers();
});

test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const draftWithLegacyHandAssets: BabyObjectMatchDraft = {
    ...createVisualPackageDraft(),
    visualPackage: {
      ...createVisualPackageDraft().visualPackage!,
      assets: [
        ...createVisualPackageDraft().visualPackage!.assets,
        {
          assetId: 'legacy-left-hand',
          assetKind: 'left-hand',
          imageSrc: 'data:image/png;base64,legacy-left-hand',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '旧左手',
        },
        {
          assetId: 'legacy-right-hand',
          assetKind: 'right-hand',
          imageSrc: 'data:image/png;base64,legacy-right-hand',
          assetObjectId: null,
          generationProvider: 'vector-engine-gpt-image-2',
          prompt: '旧右手',
        },
      ],
    },
  };
  const { container, rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={draftWithLegacyHandAssets}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={draftWithLegacyHandAssets}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'show-default-hand',
          receivedAtMs: 1,
        },
      })}
    />,
  );

  expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
  const stage = container.querySelector('.baby-object-runtime__stage');
  expect(stage).toBeInstanceOf(HTMLElement);
  expect(
    (stage as HTMLElement).style.getPropertyValue(
      '--baby-object-left-hand-image',
    ),
  ).toBe('');
  expect(
    (stage as HTMLElement).style.getPropertyValue(
      '--baby-object-right-hand-image',
    ),
  ).toBe('');
  vi.useRealTimers();
});

test('removes the gift box after smoke releases the current item', async () => {
  vi.useFakeTimers();
  render(
    <BabyObjectMatchRuntimeShell
      draft={createVisualPackageDraft()}
      random={createRandomSequence([0])}
    />,
  );

  await advanceInitialTargetPreview();

  expect(screen.getByLabelText('礼物盒')).toBeTruthy();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(620);
  });

  expect(screen.getByLabelText('礼物盒')).toBeTruthy();
  expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(640);
  });

  expect(screen.queryByLabelText('礼物盒')).toBeNull();
  expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();

  await act(async () => {
    await vi.advanceTimersByTimeAsync(620);
  });

  expect(screen.queryByLabelText('礼物盒')).toBeNull();
  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('keeps left and right baskets fixed while only the gift item is random', async () => {
  vi.useFakeTimers();
  render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0.99])}
    />,
  );

  await advanceRoundIntro();

  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
  ).toBeTruthy();
  expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
  expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
  expect(
    within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'),
  ).toBeTruthy();
  expect(
    within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('mocap hand must touch the current item before dropping it into a basket', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'drop-without-grab',
          receivedAtMs: 1,
        },
      })}
    />,
  );

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.queryByText('再想一想吧')).toBeNull();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'touch-current-item',
          receivedAtMs: 2,
        },
      })}
    />,
  );

  expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
  expect(screen.queryByText('真棒')).toBeNull();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.22, y: 0.78, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'drop-left-basket',
          receivedAtMs: 3,
        },
      })}
    />,
  );

  expect(screen.getByText('真棒')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
  vi.useRealTimers();
});

test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [
            {
              x: 0.5,
              y: 0.37,
              state: 'open_palm',
              side: 'right',
              source: 'palm_center',
              wrist: { x: 0.62, y: 0.37 },
            },
          ],
          primaryHand: {
            x: 0.5,
            y: 0.37,
            state: 'open_palm',
            side: 'right',
            source: 'palm_center',
            wrist: { x: 0.62, y: 0.37 },
          },
          leftHand: null,
          rightHand: {
            x: 0.5,
            y: 0.37,
            state: 'open_palm',
            side: 'right',
            source: 'palm_center',
            wrist: { x: 0.62, y: 0.37 },
          },
          bodyJoints: {
            rightWrist: { x: 0.64, y: 0.37 },
          },
        },
        rawPacketPreview: {
          text: 'hand-points-over-item-skeleton-wrist-away',
          receivedAtMs: 1,
        },
      })}
    />,
  );

  expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy();
  expect(screen.queryByText('真棒')).toBeNull();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [
            {
              x: 0.62,
              y: 0.37,
              state: 'open_palm',
              side: 'right',
              source: 'palm_center',
              wrist: { x: 0.5, y: 0.37 },
            },
          ],
          primaryHand: {
            x: 0.62,
            y: 0.37,
            state: 'open_palm',
            side: 'right',
            source: 'palm_center',
            wrist: { x: 0.5, y: 0.37 },
          },
          leftHand: null,
          rightHand: {
            x: 0.62,
            y: 0.37,
            state: 'open_palm',
            side: 'right',
            source: 'palm_center',
            wrist: { x: 0.62, y: 0.37 },
          },
          bodyJoints: {
            rightWrist: { x: 0.5, y: 0.37 },
          },
        },
        rawPacketPreview: {
          text: 'skeleton-wrist-over-item-hand-points-away',
          receivedAtMs: 2,
        },
      })}
    />,
  );

  expect(screen.getByTestId('baby-object-left-hand').className).toContain(
    'baby-object-runtime__hand--holding-left-corner',
  );
  vi.useRealTimers();
});

test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'touch-current-item-before-narrow-zone',
          receivedAtMs: 1,
        },
      })}
    />,
  );

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.37, y: 0.82, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'outside-enlarged-left-hitbox-center-gap',
          receivedAtMs: 2,
        },
      })}
    />,
  );

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.queryByText('再想一想吧')).toBeNull();
  expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.36, y: 0.62, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'enlarged-left-hitbox-edge',
          receivedAtMs: 3,
        },
      })}
    />,
  );

  expect(screen.getByText('真棒')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
  vi.useRealTimers();
});

test('either mocap hand can drag the current item into either basket', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
          leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
          rightHand: null,
        },
        rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 },
      })}
    />,
  );

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }],
          primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
          leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
          rightHand: null,
        },
        rawPacketPreview: { text: 'right-hand-drop-right', receivedAtMs: 2 },
      })}
    />,
  );

  expect(screen.getByText('再想一想吧')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
  vi.useRealTimers();
});

test('holding hand indicator anchors to the lower item corner by hand side', async () => {
  vi.useFakeTimers();
  const leftHandRandom = createRandomSequence([0, 0]);
  const leftHandRuntime = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={leftHandRandom}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  leftHandRuntime.rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={leftHandRandom}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
          leftHand: null,
          rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
        },
        rawPacketPreview: {
          text: 'left-hand-holding-corner',
          receivedAtMs: 1,
        },
      })}
    />,
  );

  expect(screen.getByTestId('baby-object-left-hand').className).toContain(
    'baby-object-runtime__hand--holding-left-corner',
  );

  leftHandRuntime.unmount();

  const rightHandRandom = createRandomSequence([0, 0]);
  const rightHandRuntime = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={rightHandRandom}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rightHandRuntime.rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={rightHandRandom}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: [],
          hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
          primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
          leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
          rightHand: null,
        },
        rawPacketPreview: {
          text: 'right-hand-holding-corner',
          receivedAtMs: 2,
        },
      })}
    />,
  );

  expect(screen.getByTestId('baby-object-right-hand').className).toContain(
    'baby-object-runtime__hand--holding-right-corner',
  );
  rightHandRuntime.unmount();
  vi.useRealTimers();
});

test('mocap action names do not select a basket without touching and dragging item', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  rerender(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput({
        latestCommand: {
          actions: ['wave_left_hand', 'wave_right_hand', 'wave'],
          hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
          primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
          leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
          rightHand: null,
        },
        rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 1 },
      })}
    />,
  );

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.queryByText('再想一想吧')).toBeNull();
  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('mocap unknown hand movement does not grab or select a basket', async () => {
  vi.useFakeTimers();
  const random = createRandomSequence([0, 0]);
  const { rerender } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={random}
      mocapInput={createMocapInput()}
    />,
  );

  await advanceRoundIntro();

  for (let index = 0; index < 4; index += 1) {
    const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5;
    const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37;
    rerender(
      <BabyObjectMatchRuntimeShell
        draft={createDraft()}
        random={random}
        mocapInput={createMocapInput({
          latestCommand: {
            actions: [],
            hands: [{ x, y, state: 'open_palm', side: 'unknown' }],
            primaryHand: { x, y, state: 'open_palm', side: 'unknown' },
            leftHand: null,
            rightHand: null,
          },
          rawPacketPreview: {
            text: `unknown-drag-${index + 1}`,
            receivedAtMs: index + 1,
          },
        })}
      />,
    );
  }

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.queryByText('再想一想吧')).toBeNull();
  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('left mouse hand drags a correct item into the left basket', async () => {
  vi.useFakeTimers();
  const { container } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0])}
    />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  await advanceRoundIntro();
  dragItemWithHand(stage, 0, 70);

  expect(screen.getByText('真棒')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
  expect(screen.getByLabelText('左侧篮子 苹果').className).toContain(
    'baby-object-runtime__basket--correct',
  );

  await advanceFeedback();

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
  expect(screen.getByLabelText('礼物盒')).toBeTruthy();

  await advanceRoundIntro();

  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('ignores drag input until the item animation finishes', async () => {
  vi.useFakeTimers();
  const { container } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0])}
    />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  dragItemWithHand(stage, 0, 70);

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');

  await advanceRoundIntro();
  dragItemWithHand(stage, 0, 70);

  expect(screen.getByText('真棒')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
  vi.useRealTimers();
});

test('keeps the back button outside active gameplay pointer input', async () => {
  vi.useFakeTimers();
  const onBack = vi.fn();
  render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0])}
      onBack={onBack}
    />,
  );

  await advanceRoundIntro();

  const backButton = screen.getByRole('button', { name: '返回' });
  let pointerDownEvent!: Event;
  act(() => {
    pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', {
      pointerId: 9,
      button: 0,
      buttons: 1,
      clientX: 16,
      clientY: 16,
    });
  });

  expect(pointerDownEvent.defaultPrevented).toBe(false);
  expect(screen.queryByTestId('baby-object-left-hand')).toBeNull();

  act(() => {
    backButton.click();
  });

  expect(onBack).toHaveBeenCalledTimes(1);
  vi.useRealTimers();
});

test('correct placement automatically shows the next gift item', async () => {
  vi.useFakeTimers();
  const { container } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0.99])}
    />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');

  await advanceRoundIntro();

  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();

  dragItemWithHand(stage, 0, 70);

  expect(screen.getByText('真棒')).toBeTruthy();

  await advanceFeedback();

  expect(screen.queryByText('真棒')).toBeNull();
  expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');

  await advanceRoundIntro();

  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('wrong basket keeps the item active after feedback', async () => {
  vi.useFakeTimers();
  const { container } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence([0, 0])}
    />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  await advanceRoundIntro();
  dragItemWithHand(stage, 2, 250);

  expect(screen.getByText('再想一想吧')).toBeTruthy();
  expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');

  await advanceFeedback();

  expect(screen.queryByText('再想一想吧')).toBeNull();
  expect(
    within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
  ).toBeTruthy();
  vi.useRealTimers();
});

test('twenty correct placements completes the level', async () => {
  vi.useFakeTimers();
  const randomValues = Array.from({ length: 40 }, () => 0);
  const { container } = render(
    <BabyObjectMatchRuntimeShell
      draft={createDraft()}
      random={createRandomSequence(randomValues)}
    />,
  );
  const stage = container.querySelector('.baby-object-runtime__stage');
  if (!(stage instanceof HTMLElement)) {
    throw new Error('Missing baby object runtime stage');
  }

  for (let index = 0; index < 20; index += 1) {
    await advanceRoundIntro();
    dragItemWithHand(stage, 0, 70);
    await advanceFeedback();
  }

  expect(screen.getAllByText('恭喜你！小朋友！').length).toBeGreaterThan(0);
  expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
  expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
  vi.useRealTimers();
});
