/* @vitest-environment jsdom */

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

import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
  buildMergedGroupOutlinePath,
  resolveDraggedMergedGroupLayer,
  resolveDraggedPieceCellLayer,
  resolveDraggedPieceLayer,
} from './puzzleRuntimeShape';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';

vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
  useResolvedAssetReadUrl: (src: string | null) => ({
    resolvedUrl: src ?? '',
    isResolving: false,
    shouldResolve: false,
  }),
}));

vi.mock('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => {
  const actual = await vi.importActual<
    typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser')
  >('../../services/puzzle-runtime/puzzleUiSpritesheetParser');
  return {
    ...actual,
    loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({
      width: 32,
      height: 24,
      regions: {
        back: { x: 3, y: 2, width: 4, height: 4 },
        settings: { x: 24, y: 3, width: 5, height: 5 },
        next: { x: 11, y: 11, width: 10, height: 5 },
        hint: { x: 2, y: 20, width: 5, height: 3 },
        reference: { x: 13, y: 19, width: 6, height: 4 },
        freezeTime: { x: 25, y: 20, width: 5, height: 4 },
      },
      hitRegions: {
        back: { x: 4, y: 3, width: 2, height: 2 },
        hint: { x: 3, y: 21, width: 3, height: 1 },
      },
    })),
  };
});

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

function createAuthValue() {
  return {
    user: null,
    canAccessProtectedData: false,
    openLoginModal: () => {},
    requireAuth: (action: () => void) => action(),
    openSettingsModal: () => {},
    openAccountModal: () => {},
    setCurrentUser: vi.fn(),
    logout: async () => {},
    musicVolume: 0.42,
    setMusicVolume: vi.fn(),
    platformTheme: 'light' as const,
    setPlatformTheme: vi.fn(),
    isHydratingSettings: false,
    isPersistingSettings: false,
    settingsError: null,
  };
}

function renderPuzzleRuntime(
  ui: React.ReactElement,
  authValue = createAuthValue(),
) {
  return render(
    <AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
  );
}

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

const clearedRun: PuzzleRunSnapshot = {
  runId: 'run-1',
  entryProfileId: 'profile-1',
  clearedLevelCount: 1,
  currentLevelIndex: 1,
  currentGridSize: 3,
  playedProfileIds: ['profile-1'],
  previousLevelTags: ['奇幻'],
  recommendedNextProfileId: 'profile-2',
  leaderboardEntries: [
    {
      rank: 1,
      nickname: '测试作者',
      elapsedMs: 12_340,
      isCurrentPlayer: true,
    },
    {
      rank: 2,
      nickname: '星桥旅人',
      elapsedMs: 18_120,
    },
  ],
  currentLevel: {
    runId: 'run-1',
    levelIndex: 1,
    levelId: 'puzzle-level-1',
    gridSize: 3,
    profileId: 'profile-1',
    levelName: '潮雾拼图',
    authorDisplayName: '测试作者',
    themeTags: ['奇幻'],
    coverImageSrc: null,
    status: 'cleared',
    startedAtMs: 1000,
    clearedAtMs: 13_340,
    elapsedMs: 12_340,
    timeLimitMs: 300_000,
    remainingMs: 287_660,
    pausedAccumulatedMs: 0,
    pauseStartedAtMs: null,
    freezeAccumulatedMs: 0,
    freezeStartedAtMs: null,
    freezeUntilMs: null,
    leaderboardEntries: [
      {
        rank: 1,
        nickname: '测试作者',
        elapsedMs: 12_340,
        isCurrentPlayer: true,
      },
      {
        rank: 2,
        nickname: '星桥旅人',
        elapsedMs: 18_120,
      },
    ],
    board: {
      rows: 3,
      cols: 3,
      selectedPieceId: null,
      allTilesResolved: true,
      mergedGroups: [],
      pieces: Array.from({ length: 9 }, (_, index) => ({
        pieceId: `piece-${index}`,
        correctRow: Math.floor(index / 3),
        correctCol: index % 3,
        currentRow: Math.floor(index / 3),
        currentCol: index % 3,
        mergedGroupId: null,
      })),
    },
  },
};

test('拼图界面不调用 mocap，也不渲染 mocap 光标或调试面板', () => {
  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={{
        ...clearedRun,
        currentLevel: {
          ...clearedRun.currentLevel!,
          status: 'playing',
        },
      }}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  expect(screen.queryByTestId('puzzle-mocap-debug')).toBeNull();
  expect(screen.queryByTestId('puzzle-mocap-cursor')).toBeNull();
});

test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
  const originalRequestAnimationFrame = window.requestAnimationFrame;
  const onDragPiece = vi.fn();
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 300_000,
      timeLimitMs: 300_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
        pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
          piece.pieceId === 'piece-0'
            ? { ...piece, currentRow: 0, currentCol: 0 }
            : piece,
        ),
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={onDragPiece}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const piece = container.querySelector(
    '[data-piece-id="piece-0"]',
  ) as HTMLElement | null;
  if (!piece) {
    throw new Error('缺少测试拼图片');
  }
  const board = container.querySelector(
    '[data-testid="puzzle-board"]',
  ) as HTMLElement | null;
  if (!board) {
    throw new Error('缺少测试棋盘');
  }
  const requestAnimationFrame = vi.fn(() => 1);
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: requestAnimationFrame,
  });
  board.getBoundingClientRect = () =>
    ({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 300,
      bottom: 300,
      width: 300,
      height: 300,
      toJSON: () => ({}),
    }) as DOMRect;

  act(() => {
    dispatchPointerEvent(piece, 'pointerdown', {
      pointerId: 11,
      clientX: 40,
      clientY: 40,
    });
  });
  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 11,
      clientX: 70,
      clientY: 70,
    });
  });
  expect(piece.style.transform).toBe('translate3d(30px, 30px, 0) scale(1.03)');
  expect(piece.style.transition).toBe('none');
  expect(requestAnimationFrame).not.toHaveBeenCalled();
  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 11,
      clientX: 140,
      clientY: 140,
    });
  });
  act(() => {
    dispatchPointerEvent(piece, 'pointerup', {
      pointerId: 11,
      clientX: 140,
      clientY: 140,
    });
  });

  expect(onDragPiece).toHaveBeenCalledTimes(1);
  expect(onDragPiece).toHaveBeenCalledWith(
    expect.objectContaining({ pieceId: 'piece-0' }),
  );

  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: originalRequestAnimationFrame,
  });
});

test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
  const onDragPiece = vi.fn();
  const mergedRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 300_000,
      timeLimitMs: 300_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
        mergedGroups: [
          {
            groupId: 'group-large',
            pieceIds: ['piece-0', 'piece-1', 'piece-3'],
            occupiedCells: [
              { row: 0, col: 0 },
              { row: 0, col: 1 },
              { row: 1, col: 0 },
            ],
          },
        ],
        pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
          ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
            ? { ...piece, mergedGroupId: 'group-large' }
            : piece,
        ),
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={mergedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={onDragPiece}
      onAdvanceNextLevel={vi.fn()}
    />,
  );
  const board = container.querySelector(
    '[data-testid="puzzle-board"]',
  ) as HTMLElement | null;
  if (!board) {
    throw new Error('缺少测试棋盘');
  }
  board.getBoundingClientRect = () =>
    ({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 300,
      bottom: 300,
      width: 300,
      height: 300,
      toJSON: () => ({}),
    }) as DOMRect;
  const mergedPiece = container.querySelector(
    '[data-merged-piece-outline="true"]',
  ) as HTMLElement | null;
  if (!mergedPiece) {
    throw new Error('缺少测试合并拼图片');
  }

  act(() => {
    dispatchPointerEvent(mergedPiece, 'pointerdown', {
      pointerId: 12,
      clientX: 60,
      clientY: 60,
    });
  });
  act(() => {
    dispatchPointerEvent(mergedPiece, 'pointermove', {
      pointerId: 12,
      clientX: 210,
      clientY: 210,
    });
  });
  const mergedGroup = container.querySelector(
    '[data-merged-group-id="group-large"]',
  ) as HTMLElement | null;
  expect(mergedGroup?.style.transform).toBe(
    'translate3d(150px, 150px, 0) scale(1.02)',
  );
  expect(mergedGroup?.style.transition).toBe('none');
  act(() => {
    dispatchPointerEvent(mergedPiece, 'pointerup', {
      pointerId: 12,
      clientX: 210,
      clientY: 210,
    });
  });

  expect(onDragPiece).toHaveBeenCalledTimes(1);
  expect(onDragPiece).toHaveBeenCalledWith({
    pieceId: 'piece-0',
    targetRow: 2,
    targetCol: 2,
  });
});

test('拖拽合并大块时底层单格不显示选中色块', () => {
  const originalRequestAnimationFrame = window.requestAnimationFrame;
  const originalCancelAnimationFrame = window.cancelAnimationFrame;
  const mergedRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 300_000,
      timeLimitMs: 300_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
        mergedGroups: [
          {
            groupId: 'group-large',
            pieceIds: ['piece-0', 'piece-1', 'piece-3'],
            occupiedCells: [
              { row: 0, col: 0 },
              { row: 0, col: 1 },
              { row: 1, col: 0 },
            ],
          },
        ],
        pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
          ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
            ? { ...piece, mergedGroupId: 'group-large' }
            : piece,
        ),
      },
    },
  };

  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: vi.fn(() => 1),
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    value: vi.fn(),
  });

  const { container, unmount } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={mergedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );
  const board = container.querySelector(
    '[data-testid="puzzle-board"]',
  ) as HTMLElement | null;
  if (!board) {
    throw new Error('缺少测试棋盘');
  }
  board.getBoundingClientRect = () =>
    ({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 300,
      bottom: 300,
      width: 300,
      height: 300,
      toJSON: () => ({}),
    }) as DOMRect;
  const mergedPiece = container.querySelector(
    '[data-merged-piece-outline="true"]',
  ) as HTMLElement | null;
  if (!mergedPiece) {
    throw new Error('缺少测试合并拼图片');
  }

  act(() => {
    dispatchPointerEvent(mergedPiece, 'pointerdown', {
      pointerId: 13,
      clientX: 60,
      clientY: 60,
    });
  });
  act(() => {
    dispatchPointerEvent(mergedPiece, 'pointermove', {
      pointerId: 13,
      clientX: 210,
      clientY: 210,
    });
  });

  const basePiece = container.querySelector(
    '[data-piece-id="piece-0"]',
  ) as HTMLElement | null;
  expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
  expect(basePiece?.className).not.toContain('puzzle-runtime-piece--selected');

  unmount();
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: originalRequestAnimationFrame,
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    value: originalCancelAnimationFrame,
  });
});

test('拖动拼图片时不显示已选择状态', () => {
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 300_000,
      timeLimitMs: 300_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );
  const board = container.querySelector(
    '[data-testid="puzzle-board"]',
  ) as HTMLElement | null;
  if (!board) {
    throw new Error('缺少测试棋盘');
  }
  board.getBoundingClientRect = () =>
    ({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 300,
      bottom: 300,
      width: 300,
      height: 300,
      toJSON: () => ({}),
    }) as DOMRect;
  const piece = container.querySelector(
    '[data-piece-id="piece-0"]',
  ) as HTMLElement | null;
  if (!piece) {
    throw new Error('缺少测试拼图片');
  }

  act(() => {
    dispatchPointerEvent(piece, 'pointerdown', {
      pointerId: 14,
      clientX: 40,
      clientY: 40,
    });
  });

  expect(screen.getByText('已选择')).toBeTruthy();

  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 14,
      clientX: 70,
      clientY: 70,
    });
  });

  expect(screen.queryByText('已选择')).toBeNull();
  expect(piece.className).not.toContain('puzzle-runtime-piece--selected');
});

test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
  vi.useFakeTimers();
  const onAdvanceNextLevel = vi.fn();

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={clearedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={onAdvanceNextLevel}
    />,
  );

  expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
  expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy();

  act(() => {
    vi.advanceTimersByTime(1_400);
  });

  const dialog = screen.getByRole('dialog', { name: '通关完成' });
  expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
  expect(within(dialog).getByText('排行榜')).toBeTruthy();
  expect(within(dialog).getByText('#1')).toBeTruthy();
  expect(within(dialog).getByText('测试作者')).toBeTruthy();

  const nextButton = within(dialog).getByRole('button', { name: '下一关' });
  expect(nextButton.textContent).toContain('下一关');
  expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();

  fireEvent.click(nextButton);

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

test('首次点击左上返回弹出作品改造引导，保存并退出后不再重复弹出', () => {
  const onBack = vi.fn();
  const onRemodelWork = vi.fn();
  window.localStorage.clear();

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={clearedRun}
      onBack={onBack}
      onRemodelWork={onRemodelWork}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));

  const dialog = screen.getByRole('dialog', {
    name: /体验不佳？\s*试试改造功能!/u,
  });
  expect(dialog).toBeTruthy();
  expect(onBack).not.toHaveBeenCalled();

  fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' }));

  expect(onBack).toHaveBeenCalledTimes(1);
  expect(
    window.localStorage.getItem(
      'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1',
    ),
  ).toBe('1');

  fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));

  expect(screen.queryByRole('dialog')).toBeNull();
  expect(onBack).toHaveBeenCalledTimes(2);
  expect(onRemodelWork).not.toHaveBeenCalled();
});

test('首次退出引导的作品改造按钮进入改造流程', () => {
  const onRemodelWork = vi.fn();
  window.localStorage.clear();

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={{
        ...clearedRun,
        currentLevel: {
          ...clearedRun.currentLevel!,
          profileId: 'profile-remodel',
        },
      }}
      onBack={vi.fn()}
      onRemodelWork={onRemodelWork}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
  fireEvent.click(screen.getByRole('button', { name: '作品改造' }));

  expect(onRemodelWork).toHaveBeenCalledTimes(1);
  expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel');
  expect(screen.queryByRole('dialog')).toBeNull();
});

test('顶部不显示作者，关卡标题和倒计时使用游戏铭牌结构', () => {
  const runWithoutNext: PuzzleRunSnapshot = {
    ...clearedRun,
    recommendedNextProfileId: null,
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithoutNext}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const timer = screen.getByText('4:48');
  const hintButton = screen.getByRole('button', { name: '提示' });
  const referenceButton = screen.getByRole('button', { name: '原图' });
  const freezeButton = screen.getByRole('button', { name: '冻结' });

  expect(screen.queryByText('测试作者')).toBeNull();
  expect(screen.getByText('第 1 关')).toBeTruthy();
  expect(screen.getByText('潮雾拼图')).toBeTruthy();
  const levelLogo = screen.getByTestId(
    'puzzle-runtime-level-logo',
  ) as HTMLImageElement;
  expect(levelLogo.getAttribute('src')).toContain('logo.png');
  expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
  expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
  expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();
  expect(
    screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'),
  ).toBeTruthy();
  expect(timer.className).toContain('puzzle-runtime-timer');
  expect(timer.className).toContain('text-lg');
  expect(timer.className).not.toContain('text-2xl');
  expect(hintButton.textContent).not.toContain('提示');
  expect(referenceButton.textContent).not.toContain('原图');
  expect(freezeButton.textContent).not.toContain('冻结');
  expect(hintButton.parentElement?.className).toContain('grid-cols-3');
  expect(hintButton.parentElement?.className).not.toContain(
    'puzzle-runtime-toolbar',
  );
  expect(screen.queryByText('等待下一关候选')).toBeNull();
});

test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
  const runWithUiBackground: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      coverImageSrc: '/generated-puzzle-assets/session/cover.png',
      uiBackgroundImageSrc:
        '/generated-puzzle-assets/session/ui/background.png',
      remainingMs: 300_000,
      timeLimitMs: 300_000,
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithUiBackground}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const backgroundImage = container.querySelector(
    'img[src="/generated-puzzle-assets/session/ui/background.png"]',
  );
  expect(backgroundImage).toBeTruthy();
});

test('运行态优先把关卡背景图渲染为舞台背景', async () => {
  const runWithLevelBackground: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      coverImageSrc: '/generated-puzzle-assets/session/cover.png',
      uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png',
      levelBackgroundImageSrc:
        '/generated-puzzle-assets/session/level-background/background.png',
      uiSpritesheetImageSrc:
        '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
      remainingMs: 300_000,
      timeLimitMs: 300_000,
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithLevelBackground}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  expect(
    container.querySelector(
      'img[src="/generated-puzzle-assets/session/level-background/background.png"]',
    ),
  ).toBeTruthy();
  expect(
    container.querySelector(
      'img[src="/generated-puzzle-assets/session/ui/legacy.png"]',
    ),
  ).toBeNull();
  await waitFor(() => {
    expect(
      screen
        .getByRole('button', { name: '提示' })
        .querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'),
    ).toBeTruthy();
  });
});

test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => {
  const runWithSpritesheet: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'cleared',
      coverImageSrc: '/generated-puzzle-assets/session/cover.png',
      uiSpritesheetImageSrc:
        '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
      remainingMs: 120_000,
      timeLimitMs: 300_000,
    },
    nextLevelMode: 'sameWork',
    nextLevelProfileId: 'profile-1',
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithSpritesheet}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  await screen.findByRole('button', { name: '下一关' });

  expect(
    screen
      .getByRole('button', { name: '返回上一页' })
      .querySelector('[data-puzzle-ui-sprite="back"]'),
  ).toBeTruthy();
  expect(
    screen.getByRole('button', { name: '返回上一页' }).className,
  ).toContain('puzzle-runtime-icon-button--sprite');
  expect(
    screen.getByRole('button', { name: '返回上一页' }).className,
  ).toContain('puzzle-runtime-icon-button--precise-hit');
  expect(
    screen
      .getByRole('button', { name: '返回上一页' })
      .querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'),
  ).toBeTruthy();
  expect(
    screen.getByRole('button', { name: '返回上一页' }).className,
  ).not.toContain('rounded-full');
  expect(
    screen
      .getByRole('button', { name: '打开拼图设置' })
      .querySelector('[data-puzzle-ui-sprite="settings"]'),
  ).toBeTruthy();
  expect(
    screen.getByRole('button', { name: '打开拼图设置' }).className,
  ).toContain('puzzle-runtime-icon-button--sprite');
  expect(
    screen.getByRole('button', { name: '打开拼图设置' }).className,
  ).toContain('puzzle-runtime-icon-button--precise-hit');
  expect(
    screen.getByRole('button', { name: '打开拼图设置' }).className,
  ).not.toContain('rounded-full');
  const nextButton = screen.getByRole('button', { name: '下一关' });
  expect(nextButton.dataset.puzzleUiSprite).toBe('next');
  expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();
  expect(nextButton.style.backgroundSize).toBe('320% 480%');
  expect(nextButton.style.backgroundPosition).toBe('50% 57.89473684210527%');
  expect(nextButton.className).not.toContain('puzzle-runtime-primary-button');
  expect(nextButton.className).not.toContain('rounded-full');
  expect(nextButton.className).not.toContain('px-5');
  expect(nextButton.className).not.toContain('py-2.5');
  expect(nextButton.textContent).toBe('');
  expect(
    screen
      .getByRole('button', { name: '提示' })
      .querySelector('[data-puzzle-ui-sprite="hint"]'),
  ).toBeTruthy();
  const hintHitZone = screen
    .getByRole('button', { name: '提示' })
    .querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null;
  expect(hintHitZone).toBeTruthy();
  expect(screen.getByRole('button', { name: '提示' }).className).toContain(
    'puzzle-runtime-sprite-tool-button--precise-hit',
  );
  expect(hintHitZone?.className).toContain(
    'puzzle-runtime-ui-sprite-hit-zone',
  );
  expect(hintHitZone?.style.left).toBe('20%');
  expect(hintHitZone?.style.top).toBe('33.33333333333333%');
  expect(hintHitZone?.style.width).toBe('60%');
  expect(hintHitZone?.style.height).toBe('33.33333333333333%');
  expect(
    screen
      .getByRole('button', { name: '提示' })
      .querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
  ).toContain('puzzle-runtime-bottom-ui-sprite');
  expect(
    screen
      .getByRole('button', { name: '提示' })
      .querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
  ).not.toContain('w-full');
  expect(screen.getByRole('button', { name: '提示' }).textContent).toBe('');
  const referenceSprite = screen
    .getByRole('button', { name: '原图' })
    .querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null;
  expect(referenceSprite).toBeTruthy();
  expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%');
  expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%');
  expect(referenceSprite?.style.aspectRatio).toBe('6 / 4');
  expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite');
  expect(referenceSprite?.className).not.toContain('w-full');
  expect(screen.getByRole('button', { name: '原图' }).textContent).toBe('');
  expect(
    screen
      .getByRole('button', { name: '冻结' })
      .querySelector('[data-puzzle-ui-sprite="freezeTime"]'),
  ).toBeTruthy();
  expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
});

test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
  const runWithUiBackground: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      coverImageSrc: '/generated-puzzle-assets/session/cover.png',
      uiBackgroundImageSrc: null,
      uiBackgroundImageObjectKey:
        'generated-puzzle-assets/session/ui/background-object-key.png',
      remainingMs: 300_000,
      timeLimitMs: 300_000,
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithUiBackground}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const backgroundImage = container.querySelector(
    'img[src="/generated-puzzle-assets/session/ui/background-object-key.png"]',
  );
  expect(backgroundImage).toBeTruthy();
});

test('关闭通关弹窗后保留底部下一关入口', async () => {
  vi.useFakeTimers();
  const onAdvanceNextLevel = vi.fn();
  const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
    ...clearedRun,
    recommendedNextProfileId: null,
    nextLevelMode: 'sameWork',
    nextLevelProfileId: 'profile-1',
    nextLevelId: 'puzzle-level-2',
    recommendedNextWorks: [],
    currentLevel: {
      ...clearedRun.currentLevel!,
      uiSpritesheetImageSrc:
        '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithoutRecommendedNextProfile}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={onAdvanceNextLevel}
    />,
  );
  await act(async () => {});

  act(() => {
    vi.advanceTimersByTime(1_400);
  });
  const dialog = screen.getByRole('dialog', { name: '通关完成' });
  const dialogNextButton = within(dialog).getByRole('button', {
    name: '下一关',
  });
  expect(dialogNextButton.dataset.puzzleUiSprite).toBe('next');
  expect(
    dialogNextButton.querySelector('[data-puzzle-ui-sprite="next"]'),
  ).toBeNull();
  expect(dialogNextButton.style.backgroundSize).toBe('320% 480%');
  expect(dialogNextButton.style.backgroundPosition).toBe(
    '50% 57.89473684210527%',
  );
  expect(dialogNextButton.className).not.toContain(
    'puzzle-runtime-primary-button',
  );
  expect(dialogNextButton.className).not.toContain('rounded-full');
  expect(dialogNextButton.className).not.toContain('px-5');
  expect(dialogNextButton.className).not.toContain('py-2.5');
  expect(dialogNextButton.textContent).toBe('');

  act(() => {
    fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
  });

  expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
  const nextButton = screen.getByRole('button', { name: /下一关/u });
  expect(nextButton).toBeTruthy();

  fireEvent.click(nextButton);

  expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
  expect(onAdvanceNextLevel).toHaveBeenCalledWith({
    profileId: 'profile-1',
    levelId: 'puzzle-level-2',
  });
  vi.useRealTimers();
});

test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => {
  vi.useFakeTimers();
  const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
    ...clearedRun,
    recommendedNextProfileId: null,
    nextLevelMode: 'sameWork',
    nextLevelProfileId: 'profile-1',
    nextLevelId: 'puzzle-level-2',
    recommendedNextWorks: [],
    currentLevel: {
      ...clearedRun.currentLevel!,
      uiSpritesheetImageSrc:
        '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={runWithoutRecommendedNextProfile}
      embedded
      hideBackButton
      hideExitControls
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  act(() => {
    vi.advanceTimersByTime(1_400);
  });
  act(() => {
    fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
  });
  await act(async () => {});

  expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
  const nextButton = screen.getByRole('button', { name: /下一关/u });
  expect(nextButton).toBeTruthy();
  expect(nextButton.dataset.puzzleUiSprite).toBe('next');
  expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();
  expect(nextButton.textContent?.trim()).toBe('');
  vi.useRealTimers();
});

test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
  vi.useFakeTimers();
  const onAdvanceNextLevel = vi.fn();
  const similarWorksRun: PuzzleRunSnapshot = {
    ...clearedRun,
    recommendedNextProfileId: 'profile-similar-1',
    nextLevelMode: 'similarWorks',
    nextLevelProfileId: 'profile-similar-1',
    nextLevelId: null,
    recommendedNextWorks: [
      {
        profileId: 'profile-similar-1',
        levelName: '雾海遗迹',
        authorDisplayName: '星桥旅人',
        themeTags: ['奇幻', '遗迹'],
        coverImageSrc: null,
        similarityScore: 0.91,
      },
      {
        profileId: 'profile-similar-2',
        levelName: '风塔试炼',
        authorDisplayName: '晨风',
        themeTags: ['奇幻', '机关'],
        coverImageSrc: null,
        similarityScore: 0.84,
      },
      {
        profileId: 'profile-similar-3',
        levelName: '月井秘路',
        authorDisplayName: '月井守望',
        themeTags: ['秘境', '魔法'],
        coverImageSrc: null,
        similarityScore: 0.79,
      },
    ],
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={similarWorksRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={onAdvanceNextLevel}
    />,
  );

  act(() => {
    vi.advanceTimersByTime(1_400);
  });

  const dialog = screen.getByRole('dialog', { name: '通关完成' });
  expect(within(dialog).getByText('雾海遗迹')).toBeTruthy();
  expect(within(dialog).getByText('风塔试炼')).toBeTruthy();
  expect(within(dialog).getByText('月井秘路')).toBeTruthy();
  expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull();

  fireEvent.click(within(dialog).getByRole('button', { name: /风塔试炼/u }));

  expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
  expect(onAdvanceNextLevel).toHaveBeenCalledWith({
    profileId: 'profile-similar-2',
  });
  vi.useRealTimers();
});

test('当前作品没有下一关时底部入口打开相似作品选择', () => {
  vi.useFakeTimers();
  const similarWorksRun: PuzzleRunSnapshot = {
    ...clearedRun,
    recommendedNextProfileId: 'profile-similar-1',
    nextLevelMode: 'similarWorks',
    nextLevelProfileId: 'profile-similar-1',
    nextLevelId: null,
    recommendedNextWorks: [
      {
        profileId: 'profile-similar-1',
        levelName: '雾海遗迹',
        authorDisplayName: '星桥旅人',
        themeTags: ['奇幻', '遗迹'],
        coverImageSrc: null,
        similarityScore: 0.91,
      },
    ],
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={similarWorksRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  act(() => {
    vi.advanceTimersByTime(1_400);
  });
  fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));

  fireEvent.click(screen.getByRole('button', { name: /换个作品/u }));

  expect(screen.getByRole('dialog', { name: '通关完成' })).toBeTruthy();
  expect(screen.getByRole('button', { name: /雾海遗迹/u })).toBeTruthy();
  vi.useRealTimers();
});

test('右上角设置按钮打开拼图设置并支持音量调节', () => {
  const authValue = createAuthValue();

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={clearedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
    authValue,
  );

  const settingsButton = screen.getByRole('button', { name: '打开拼图设置' });
  expect(settingsButton.querySelector('img')).toBeNull();
  expect(settingsButton.querySelector('svg')).toBeTruthy();

  fireEvent.click(settingsButton);

  const dialog = screen.getByRole('dialog', { name: '拼图设置' });
  const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
  fireEvent.change(slider, { target: { value: '77' } });

  expect(within(dialog).getByText('第 1 关')).toBeTruthy();
  expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});

test('拼图设置面板展示进行中关卡的实时当前用时', () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-05-15T08:00:10.000Z'));
  const startedAtMs = Date.now() - 8_500;
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    clearedLevelCount: 0,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs,
      clearedAtMs: null,
      elapsedMs: null,
      remainingMs: 291_500,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));

  const dialog = screen.getByRole('dialog', { name: '拼图设置' });
  expect(within(dialog).getByText('0:08.50')).toBeTruthy();
  vi.useRealTimers();
});

test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={clearedRun}
      embedded
      hideExitControls
      onBack={vi.fn()}
      onRemodelWork={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const backButton = screen.getByRole('button', { name: '返回上一页' });
  expect((backButton as HTMLButtonElement).disabled).toBe(true);
  expect(backButton.className).toContain('invisible');

  fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));

  const dialog = screen.getByRole('dialog', { name: '拼图设置' });
  expect(within(dialog).getByRole('button', { name: '继续拼图' })).toBeTruthy();
  expect(
    within(dialog).queryByRole('button', { name: '返回上一页' }),
  ).toBeNull();
  expect(within(dialog).queryByText('保存并退出')).toBeNull();
});

test('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => {
  vi.useFakeTimers();

  renderPuzzleRuntime(
    <div className="platform-recommend-runtime-viewport">
      <PuzzleRuntimeShell
        run={clearedRun}
        embedded
        hideExitControls
        onBack={vi.fn()}
        onSwapPieces={vi.fn()}
        onDragPiece={vi.fn()}
        onAdvanceNextLevel={vi.fn()}
      />
    </div>,
  );

  act(() => {
    vi.advanceTimersByTime(1_400);
  });

  const dialog = screen.getByRole('dialog', { name: '通关完成' });
  const overlay = dialog.closest('.puzzle-runtime-modal-overlay');

  expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed');
  expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull();

  vi.useRealTimers();
});

test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={{
        ...clearedRun,
        currentLevel: {
          ...clearedRun.currentLevel!,
          status: 'playing',
          startedAtMs: Date.now(),
          board: {
            ...clearedRun.currentLevel!.board,
            allTilesResolved: false,
          },
        },
      }}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const board = screen.getByTestId('puzzle-board');
  expect(board.className).toContain('puzzle-runtime-board');
  expect(board.className).toContain('aspect-square');
  expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_14rem))]');
  expect(board.className).not.toContain('aspect-video');
  expect(board.className).not.toContain('aspect-[9/16]');
  expect(board.getAttribute('style')).toContain('grid-template-rows');
  expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
});

test('拼图运行态主体使用主题语义类承接明暗主题', () => {
  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={null}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  expect(container.firstElementChild?.className).toContain(
    'puzzle-runtime-shell',
  );
  expect(container.firstElementChild?.className).toContain(
    'platform-theme--light',
  );
  expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
});

test('拼图直达页在没有外层主题壳时也会自行补齐平台主题类', () => {
  const { container } = render(
    <PuzzleRuntimeShell
      run={null}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  expect(container.firstElementChild?.className).toContain(
    'platform-theme--light',
  );
});

test('合并块不叠加可见轮廓和单块阴影', () => {
  const mergedRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      coverImageSrc: '/puzzle.png',
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
        mergedGroups: [
          {
            groupId: 'group-l',
            pieceIds: ['piece-0', 'piece-1', 'piece-3'],
            occupiedCells: [
              { row: 0, col: 0 },
              { row: 0, col: 1 },
              { row: 1, col: 0 },
            ],
          },
        ],
        pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
          ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
            ? { ...piece, mergedGroupId: 'group-l' }
            : piece,
        ),
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={mergedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );
  const outlinedPieces = container.querySelectorAll(
    '[data-merged-piece-outline="true"]',
  );

  expect(outlinedPieces).toHaveLength(3);
  expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
  const mergedGroupClipLayer = container.querySelector(
    '[data-merged-group-id="group-l"] [data-merged-group-clip="true"]',
  ) as SVGSVGElement | null;
  const mergedGroupClipPath = mergedGroupClipLayer?.querySelector('path');
  const mergedPieceVisuals = container.querySelectorAll(
    '[data-merged-piece-visual="true"]',
  );
  expect(mergedGroupClipLayer?.tagName.toLowerCase()).toBe('svg');
  expect(mergedGroupClipLayer?.getAttribute('viewBox')).toBe('0 0 2 2');
  expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 2 1 1.84 1');
  expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 1 1 1 1.192');
  expect(mergedPieceVisuals).toHaveLength(3);
  const mergedImageSlices = mergedGroupClipLayer?.querySelectorAll('image');
  expect(mergedImageSlices).toHaveLength(3);
  expect(mergedImageSlices?.[0]?.getAttribute('href')).toBe('/puzzle.png');
  expect(mergedImageSlices?.[0]?.getAttribute('width')).toBe('3');
  expect(mergedImageSlices?.[0]?.getAttribute('height')).toBe('3');
  for (const outlinedPiece of outlinedPieces) {
    const outlinedPieceElement = outlinedPiece as HTMLElement;
    expect(outlinedPieceElement.style.clipPath).toBe('');
    expect(outlinedPieceElement.querySelector('.absolute.inset-0')).toBeNull();
    expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
    expect(outlinedPieceElement.className).not.toContain('shadow-[');
    expect(
      outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
    ).toBeNull();
  }
});

test('合并块轮廓路径为内凹角生成圆角曲线', () => {
  const outlinePath = buildMergedGroupOutlinePath({
    rowSpan: 2,
    colSpan: 2,
    pieces: [
      {
        localRow: 0,
        localCol: 0,
      },
      {
        localRow: 0,
        localCol: 1,
      },
      {
        localRow: 1,
        localCol: 0,
      },
    ],
  });

  expect(outlinePath).toContain('Q 2 1 1.84 1');
  expect(outlinePath).toContain('Q 1 1 1 1.192');
});

test('基础单块使用圆角裁切且不叠加图片蒙版', () => {
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      coverImageSrc: '/puzzle.png',
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );

  const basePiece = container.querySelector(
    '[data-piece-id="piece-0"]',
  ) as HTMLElement | null;
  expect(basePiece?.className).toContain('overflow-hidden');
  expect(basePiece?.className).toContain('border-0');
  expect(basePiece?.style.clipPath).toContain('url(#');
  expect(basePiece?.className).not.toContain('border-2');
  expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
});

test('移动端点击拼图片时立即触发一次震动反馈', () => {
  const originalVibrate = navigator.vibrate;
  const originalRequestAnimationFrame = window.requestAnimationFrame;
  const originalCancelAnimationFrame = window.cancelAnimationFrame;
  const vibrate = vi.fn();
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  Object.defineProperty(navigator, 'vibrate', {
    configurable: true,
    value: vibrate,
  });
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: vi.fn(() => 1),
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    value: vi.fn(),
  });

  const { container, unmount } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
    />,
  );
  const piece = container.querySelector(
    '[data-piece-id="piece-0"]',
  ) as HTMLElement | null;
  if (!piece) {
    throw new Error('缺少测试拼图片');
  }

  act(() => {
    dispatchPointerEvent(piece, 'pointerdown', {
      pointerId: 1,
      clientX: 100,
      clientY: 100,
    });
  });

  expect(vibrate).toHaveBeenCalledTimes(1);
  expect(vibrate).toHaveBeenCalledWith([12]);

  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 1,
      clientX: 104,
      clientY: 104,
    });
  });

  expect(vibrate).toHaveBeenCalledTimes(1);

  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 1,
      clientX: 112,
      clientY: 100,
    });
  });
  act(() => {
    dispatchPointerEvent(piece, 'pointermove', {
      pointerId: 1,
      clientX: 132,
      clientY: 100,
    });
  });

  expect(vibrate).toHaveBeenCalledTimes(1);

  unmount();
  Object.defineProperty(navigator, 'vibrate', {
    configurable: true,
    value: originalVibrate,
  });
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    value: originalRequestAnimationFrame,
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    value: originalCancelAnimationFrame,
  });
});

test('道具确认弹窗暂停时间，提示只演示不直接移动拼块', async () => {
  const onPauseChange = vi.fn();
  const onUseProp = vi.fn().mockResolvedValue(clearedRun);
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 180_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
        pieces: clearedRun.currentLevel!.board.pieces.map((piece, index) => {
          if (index === 0) {
            return { ...piece, currentRow: 2, currentCol: 2 };
          }
          if (index === 8) {
            return { ...piece, currentRow: 0, currentCol: 0 };
          }
          return piece;
        }),
      },
    },
  };

  const { container } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onPauseChange={onPauseChange}
      onUseProp={onUseProp}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '提示' }));
  expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
  expect(screen.getByText('消耗 1 泥点')).toBeTruthy();
  expect(onPauseChange).toHaveBeenLastCalledWith(true);

  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  expect(onUseProp).toHaveBeenCalledWith('hint');
  expect(onPauseChange).toHaveBeenLastCalledWith(false);
  expect(
    (container.querySelector('[data-piece-cell-id="piece-0"]') as HTMLElement)
      .style.transform,
  ).toBe('translate(-66.66666666666666%, -66.66666666666666%) scale(1.03)');
});

test('道具使用失败时保留确认弹窗和暂停态', async () => {
  const onPauseChange = vi.fn();
  const onUseProp = vi.fn().mockRejectedValue(new Error('泥点余额不足'));
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 180_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onPauseChange={onPauseChange}
      onUseProp={onUseProp}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '冻结' }));
  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
  expect(screen.getByText('泥点余额不足')).toBeTruthy();
  expect(onPauseChange).toHaveBeenLastCalledWith(true);
});

test('冻结确认期间后端同步失败态时关闭确认窗并展示失败面板', async () => {
  const onUseProp = vi.fn().mockResolvedValue({
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'failed',
      elapsedMs: 180_000,
      remainingMs: 0,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  });
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 180_000,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  const { rerender } = renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onUseProp={onUseProp}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '冻结' }));
  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  rerender(
    <AuthUiContext.Provider value={createAuthValue()}>
      <PuzzleRuntimeShell
        run={{
          ...playingRun,
          currentLevel: {
            ...playingRun.currentLevel!,
            status: 'failed',
            elapsedMs: 180_000,
            remainingMs: 0,
          },
        }}
        onBack={vi.fn()}
        onSwapPieces={vi.fn()}
        onDragPiece={vi.fn()}
        onAdvanceNextLevel={vi.fn()}
        onUseProp={onUseProp}
      />
    </AuthUiContext.Provider>,
  );

  expect(screen.queryByRole('dialog', { name: '冻结时间' })).toBeNull();
  expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
  expect(screen.queryByTestId('puzzle-freeze-effect')).toBeNull();
});

test('倒计时归零时通知父层同步失败态', () => {
  vi.useFakeTimers();
  const onTimeExpired = vi.fn();
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now() - 181_000,
      timeLimitMs: 180_000,
      remainingMs: 0,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onTimeExpired={onTimeExpired}
    />,
  );

  expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
  expect(onTimeExpired).toHaveBeenCalledTimes(1);
  vi.useRealTimers();
});

test('失败弹窗支持重开当前关和续时确认', async () => {
  const onRestartLevel = vi.fn();
  const onUseProp = vi.fn().mockResolvedValue({
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      remainingMs: 60_000,
    },
  });
  const failedRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'failed',
      elapsedMs: 180_000,
      remainingMs: 0,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={failedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onRestartLevel={onRestartLevel}
      onUseProp={onUseProp}
    />,
  );

  const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
  fireEvent.click(
    within(failedDialog).getByRole('button', { name: '重新开始' }),
  );
  expect(onRestartLevel).toHaveBeenCalledTimes(1);

  fireEvent.click(
    within(failedDialog).getByRole('button', { name: '继续1分钟' }),
  );
  expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
  expect(screen.getByText('消耗 1 泥点')).toBeTruthy();

  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  expect(onUseProp).toHaveBeenCalledWith('extendTime');
});

test('失败续时扣费失败时保留确认弹窗', async () => {
  const onUseProp = vi.fn().mockRejectedValue(new Error('泥点余额不足'));
  const failedRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'failed',
      elapsedMs: 180_000,
      remainingMs: 0,
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={failedRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onUseProp={onUseProp}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '继续1分钟' }));
  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
  expect(screen.getByText('泥点余额不足')).toBeTruthy();
});

test('查看原图显示独立原图层并在关闭后恢复计时', async () => {
  const onPauseChange = vi.fn();
  const onUseProp = vi.fn().mockResolvedValue(clearedRun);
  const playingRun: PuzzleRunSnapshot = {
    ...clearedRun,
    currentLevel: {
      ...clearedRun.currentLevel!,
      status: 'playing',
      startedAtMs: Date.now(),
      remainingMs: 180_000,
      coverImageSrc: '/puzzle.png',
      board: {
        ...clearedRun.currentLevel!.board,
        allTilesResolved: false,
      },
    },
  };

  renderPuzzleRuntime(
    <PuzzleRuntimeShell
      run={playingRun}
      onBack={vi.fn()}
      onSwapPieces={vi.fn()}
      onDragPiece={vi.fn()}
      onAdvanceNextLevel={vi.fn()}
      onPauseChange={onPauseChange}
      onUseProp={onUseProp}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '原图' }));
  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: '确定' }));
  });

  expect(onUseProp).toHaveBeenCalledWith('reference');
  const originalViewer = screen.getByTestId('puzzle-original-viewer');
  const board = screen.getByTestId('puzzle-board');
  expect(originalViewer).toBeTruthy();
  expect(originalViewer.parentElement).not.toBe(board);
  expect(
    within(originalViewer)
      .getByRole('img', { name: '潮雾拼图 原图' })
      .getAttribute('src'),
  ).toBe('/puzzle.png');
  expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
  expect(onPauseChange).toHaveBeenLastCalledWith(true);

  fireEvent.click(screen.getByRole('button', { name: '关闭原图' }));

  expect(screen.queryByTestId('puzzle-original-viewer')).toBeNull();
  expect(onPauseChange).toHaveBeenLastCalledWith(false);
});

test('拖拽层级辅助函数只提升当前被拖动对象', () => {
  expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80);
  expect(
    resolveDraggedPieceCellLayer('piece-0', 'piece-1', false),
  ).toBeUndefined();
  expect(
    resolveDraggedPieceCellLayer('piece-0', 'piece-0', true),
  ).toBeUndefined();

  expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81);
  expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined();
  expect(resolveDraggedPieceLayer('piece-0', 'piece-0', true)).toBeUndefined();

  expect(resolveDraggedMergedGroupLayer('group-1', 'group-1')).toBe(90);
  expect(resolveDraggedMergedGroupLayer('group-1', 'group-2')).toBeUndefined();
});
