/* @vitest-environment jsdom */

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

import type {
  JumpHopRuntimeRunSnapshotResponse,
  JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import {
  JUMP_HOP_THREE_CAMERA_UP_Y,
  JumpHopRuntimeShell,
  getJumpHopThreeProjectedY,
} from './JumpHopRuntimeShell';

vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
  useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
    resolvedUrl: source?.trim() ?? '',
    isResolving: false,
    shouldResolve: Boolean(source?.trim().startsWith('/generated-')),
  })),
}));

vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
  useJumpHopLeaderboard: vi.fn(),
}));

beforeEach(() => {
  vi.clearAllMocks();
  vi.mocked(useJumpHopLeaderboard).mockReturnValue({
    leaderboard: null,
    isLoading: false,
    error: null,
    refresh: vi.fn(),
  });
});

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);
}

test('跳一跳运行态松手时只提交长按蓄力值', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const run = buildRun();

  render(
    <JumpHopRuntimeShell
      profile={buildProfile()}
      run={run}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(360);
  });

  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });

  expect(onJump).toHaveBeenCalledTimes(1);
  const jumpPayload = onJump.mock.calls[0]?.[0];
  expect(jumpPayload?.dragVectorX).toBeUndefined();
  expect(jumpPayload?.dragVectorY).toBeUndefined();
  expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360);
  expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380);
  vi.useRealTimers();
});

test('跳一跳运行态手指移动不改变提交方向', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const run = buildRun();

  render(
    <JumpHopRuntimeShell
      profile={buildProfile()}
      run={run}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 40,
      clientY: 40,
    });
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 10,
      clientY: 20,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(240);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 10,
      clientY: 20,
    });
  });

  const jumpPayload = onJump.mock.calls[0]?.[0];
  expect(jumpPayload?.dragVectorX).toBeUndefined();
  expect(jumpPayload?.dragVectorY).toBeUndefined();
  expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
  expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
  vi.useRealTimers();
});

test('跳一跳运行态长按蓄力不会超过后端上限', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const baseRun = buildRun();
  const run: JumpHopRuntimeRunSnapshotResponse = {
    ...baseRun,
    path: {
      ...baseRun.path,
      scoring: {
        ...baseRun.path.scoring,
        maxChargeMs: 300,
      },
    },
  };

  render(
    <JumpHopRuntimeShell
      profile={buildProfile()}
      run={run}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 40,
      clientY: 40,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(780);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 40,
      clientY: 40,
    });
  });

  expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300);
  vi.useRealTimers();
});

test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => {
  const onJump = vi.fn().mockResolvedValue(undefined);

  render(
    <JumpHopRuntimeShell
      profile={buildProfile()}
      run={buildRun()}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });

  expect(screen.queryByText('起跳')).toBeNull();
  expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
  expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
  expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
});

test('跳一跳蓄力时角色只做垂直压缩', async () => {
  vi.useFakeTimers();
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(180);
  });

  const character = screen.getByTestId('jump-hop-character-logo')
    .parentElement as HTMLElement;
  const stretchTransform = character.style.getPropertyValue(
    '--jump-hop-character-stretch-transform',
  );
  const styleText = Array.from(document.querySelectorAll('style'))
    .map((style) => style.textContent ?? '')
    .join('\n');

  expect(stretchTransform).toMatch(/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/);
  const scaleMatch = stretchTransform.match(
    /^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/,
  );
  const scaleX = Number(scaleMatch?.groups?.x ?? 1);
  const scaleY = Number(scaleMatch?.groups?.y ?? 1);
  expect(scaleX).toBeGreaterThan(1);
  expect(scaleY).toBeLessThan(1);
  expect(scaleY).toBeLessThan(scaleX);
  expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
  expect(styleText).not.toContain(
    'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
  );
  vi.useRealTimers();
});

test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
  const runtimeRequestOptions = {
    runtimeGuestToken: 'runtime-guest-token',
  };

  render(
    <JumpHopRuntimeShell
      profile={buildProfile()}
      run={buildRun()}
      runtimeRequestOptions={runtimeRequestOptions}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
  expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy();
  expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
  expect(screen.queryByRole('button', { name: /重开/ })).toBeNull();
  expect(screen.queryByText('进行中')).toBeNull();
  expect(screen.queryByText('00:00')).toBeNull();
  expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
});

test('跳一跳运行态背景和游戏舞台覆盖全部界面且 HUD 使用独立主题按钮和拼图顶部样式', () => {
  const backButtonAsset = {
    assetId: 'jump-hop-back-button',
    imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
    imageObjectKey:
      'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
    assetObjectId: 'asset-back-button',
    generationProvider: 'vector-engine-gpt-image-2',
    prompt: '主题返回按钮',
    width: 1024,
    height: 1024,
  } satisfies NonNullable<JumpHopWorkProfileResponse['backButtonAsset']>;

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ backButtonAsset })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  expect(stage.className).toContain('absolute');
  expect(stage.className).toContain('inset-0');
  expect(stage.className).toContain('h-full');
  expect(stage.className).toContain('w-full');
  expect(stage.className).not.toContain('rounded-[1.5rem]');

  const backButton = screen.getByRole('button', { name: '返回' });
  expect(backButton.className).toContain('pointer-events-auto');
  expect(backButton.className).toContain('jump-hop-runtime__back-button');
  expect(backButton.className).toContain('h-14');
  expect(backButton.className).toContain('w-14');
  expect(backButton.className).toContain('sm:h-[3.875rem]');
  expect(backButton.className).toContain('sm:w-[3.875rem]');
  expect(backButton.getAttribute('data-has-asset')).toBe('true');
  expect(backButton.textContent).toBe('');
  expect(
    screen
      .getByTestId('jump-hop-runtime-back-button-asset')
      .getAttribute('src'),
  ).toBe(backButtonAsset.imageSrc);

  const header = backButton.closest('header');
  expect(header?.className).toContain('absolute');
  expect(header?.className).toContain('top-0');
  expect(header?.className).toContain('z-[130]');
  expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy();
  const titleCard = header?.querySelector('.puzzle-runtime-level-title-card');
  expect(titleCard).toBeTruthy();
  expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card');
  expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy();
  expect(screen.getByText('得分')).toBeTruthy();
  expect(screen.queryByText('跳一跳')).toBeNull();

  const scoreCard = screen.getByTestId('jump-hop-score-card');
  expect(scoreCard.className).toContain('puzzle-runtime-timer-card');
  expect(scoreCard.className).toContain('puzzle-runtime-timer');
  expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card');
  expect(scoreCard.className).toContain('justify-center');
  expect(scoreCard.className).toContain('text-center');
});

test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
  const runtimeRequestOptions = {
    runtimeGuestToken: 'runtime-guest-token',
  };
  vi.mocked(useJumpHopLeaderboard).mockReturnValue({
    leaderboard: {
      profileId: 'jump-hop-profile-test',
      items: [
        {
          rank: 1,
          playerId: 'user-secret-1',
          displayName: '陶泥儿玩家',
          successfulJumpCount: 8,
          durationMs: 8123,
          updatedAt: '2026-05-27T00:00:00Z',
        },
      ],
      viewerBest: null,
    },
    isLoading: false,
    error: null,
    refresh: vi.fn(),
  });

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ publicationStatus: 'published' })}
      run={buildFailedRun()}
      runtimeRequestOptions={runtimeRequestOptions}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  expect(useJumpHopLeaderboard).toHaveBeenCalledWith(
    'jump-hop-profile-test',
    runtimeRequestOptions,
  );
  expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
  const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
  expect(leaderboard).toBeTruthy();
  expect(within(leaderboard).getByText('陶泥儿玩家')).toBeTruthy();
  expect(within(leaderboard).queryByText('user-secret-1')).toBeNull();
  expect(within(leaderboard).getByText('8 跳')).toBeTruthy();
  expect(within(leaderboard).getByText('00:08')).toBeTruthy();
});

test('跳一跳草稿运行失败后不请求公开排行榜', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ publicationStatus: 'draft' })}
      run={buildFailedRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
  expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
  expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
});

test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const threeScene = screen.getByTestId('jump-hop-three-scene');
  const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
    ?.parentElement?.parentElement as HTMLElement | undefined;

  expect(threeScene.style.zIndex).toBe('42');
  expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
    Number(firstPlatform?.style.zIndex ?? 0),
  );
});

test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => {
  expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1);
  expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284);
  expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
});

test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
  const onJump = vi.fn().mockResolvedValue(undefined);

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });

  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 148,
      clientY: 454,
    });
  });

  expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
  expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();

  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 112,
      clientY: 492,
    });
  });

  expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
  expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
  expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
});

test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const tileImages = screen.getAllByTestId('jump-hop-tile-image');
  expect(tileImages).toHaveLength(3);
  expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
  const generatedReadUrlCalls = vi
    .mocked(useResolvedAssetReadUrl)
    .mock.calls.filter(([source]) =>
      source?.includes('/generated-jump-hop-assets/'),
    );
  expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
  for (const [, options] of generatedReadUrlCalls) {
    expect(options).toEqual(
      expect.objectContaining({
        refreshKey: expect.stringMatching(/^asset-object-/),
      }),
    );
  }

  for (const image of tileImages) {
    expect(image.getAttribute('src')).toContain(
      '/generated-jump-hop-assets/jump-hop-profile-test/tile-',
    );
    fireEvent.load(image);
    expect(image.getAttribute('data-loaded')).toBe('true');
  }
});

test('跳一跳运行态提前预加载下一屏地块且不在真实图片加载前露出原型方块', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRunWithExtraPreviewPlatform()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3);
  expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
  const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
  expect(preloadImages.length).toBeGreaterThan(0);
  expect(preloadImages[0]?.getAttribute('src')).toContain(
    '/generated-jump-hop-assets/jump-hop-profile-test/tile-',
  );
});

test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张图', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets({ withFaceAssets: true }) })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
  const faceImageSources = preloadImages
    .map((image) => image.getAttribute('src') ?? '')
    .filter((source) =>
      source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'),
    );

  const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/);
  const firstTileNumber = firstTileMatch?.[1];
  expect(firstTileNumber).toBeTruthy();
  expect(faceImageSources).toEqual(
    expect.arrayContaining([
      expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`),
      expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`),
      expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`),
      expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`),
      expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`),
      expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`),
    ]),
  );
  const frontSource = `/tile-${firstTileNumber}-front/image.png`;
  const frontRefreshKey = `asset-object-${firstTileNumber}-front`;
  expect(
    vi
      .mocked(useResolvedAssetReadUrl)
      .mock.calls.some(
        ([source, options]) =>
          source?.includes(frontSource) && options?.refreshKey === frontRefreshKey,
      ),
  ).toBe(true);
});

test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const tileImages = screen.getAllByTestId('jump-hop-tile-image');
  expect(tileImages).toHaveLength(3);
  const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
  const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
  const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
  expect(first?.style.top).toBe('64%');
  expect(second?.style.top).toBe('47%');
  expect(third?.style.top).toBe('30%');
});

test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0]
    ?.parentElement?.parentElement as HTMLElement | undefined;

  expect(firstTile?.style.width).toBe('116px');
  expect(firstTile?.style.height).toBe('96px');
  expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
    '1.08',
  );
});

test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const logo = screen.getByTestId('jump-hop-character-logo');
  expect(logo.getAttribute('src')).toBe(
    '/branding/jump-hop-taonier-character.png',
  );
  expect(
    screen.queryByTestId('jump-hop-character-fallback-shape'),
  ).toBeNull();
});

test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => {
  vi.useFakeTimers();

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  const canvas = screen.getByTestId('jump-hop-three-canvas');

  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    vi.advanceTimersByTime(520);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 160,
      clientY: 460,
    });
  });

  expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas);
  vi.useRealTimers();
});

test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const initialRun = buildRun();
  const nextRun: JumpHopRuntimeRunSnapshotResponse = {
    ...buildRun(),
    currentPlatformIndex: 1,
    successfulJumpCount: 1,
    score: 1,
    lastJump: {
      chargeMs: 420,
      jumpDistance: 1.68,
      targetPlatformIndex: 1,
      landedX: 0.93,
      landedY: 1.4,
      result: 'hit',
    },
  };
  const { rerender } = render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={initialRun}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(420);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });

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

  const character = screen.getByTestId('jump-hop-character-logo')
    .parentElement as HTMLElement;
  expect(stage.getAttribute('data-jump-animating')).toBe('true');
  expect(stage.getAttribute('data-platform-advancing')).toBe('false');
  expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2);
  expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe(
    '0px',
  );
  expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe(
    '0px',
  );

  rerender(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={nextRun}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
    'false',
  );
  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
    'true',
  );
  vi.useRealTimers();
});

test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const run: JumpHopRuntimeRunSnapshotResponse = {
    ...buildRun(),
    currentPlatformIndex: 1,
    successfulJumpCount: 1,
    score: 1,
    lastJump: {
      chargeMs: 300,
      jumpDistance: 1.0,
      targetPlatformIndex: 1,
      landedX: 0,
      landedY: 1.2,
      result: 'hit',
    },
  };

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={run}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const character = screen.getByTestId('jump-hop-character-logo')
    .parentElement as HTMLElement;
  const initialLeft = Number.parseFloat(character.style.left);
  const initialTop = Number.parseFloat(character.style.top);
  const stage = screen.getByTestId('jump-hop-stage');

  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(120);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });

  expect(onJump).toHaveBeenCalledTimes(1);
  expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft);
  expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop);
  vi.useRealTimers();
});

test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
  vi.useFakeTimers();
  const onJump = vi.fn().mockResolvedValue(undefined);
  const initialRun = buildRun();
  const nextRun: JumpHopRuntimeRunSnapshotResponse = {
    ...buildRun(),
    currentPlatformIndex: 1,
    successfulJumpCount: 1,
    score: 1,
    lastJump: {
      chargeMs: 420,
      jumpDistance: 1.68,
      targetPlatformIndex: 1,
      landedX: 0.93,
      landedY: 1.4,
      result: 'hit',
    },
  };
  const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = {
    ...buildRunWithExtraPreviewPlatform(),
    currentPlatformIndex: 2,
    successfulJumpCount: 2,
    score: 2,
    lastJump: {
      chargeMs: 360,
      jumpDistance: 1.44,
      targetPlatformIndex: 2,
      landedX: -0.2,
      landedY: 2.4,
      result: 'hit',
    },
  };
  const { rerender } = render(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={initialRun}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  const stage = screen.getByTestId('jump-hop-stage');
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 1,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointermove', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(420);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 1,
      clientX: 132,
      clientY: 478,
    });
  });

  expect(onJump).toHaveBeenCalledTimes(1);
  expect(stage.getAttribute('data-jump-animating')).toBe('true');
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
    '64%',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
    'true',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
    'p0',
  );

  rerender(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={nextRun}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
    'true',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
    '64%',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
    'true',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
    'p0',
  );

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

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
    'false',
  );
  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
    'true',
  );
  const landedCharacter = screen.getByTestId('jump-hop-character-logo')
    .parentElement as HTMLElement;
  expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
  expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1);
  expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1);
  expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
    '0px',
  );
  expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe(
    '0px',
  );
  const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
  expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
  expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe(
    '1.3',
  );
  expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
    '-17%',
  );
  expect(
    Number.parseFloat(
      cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
    ),
  ).toBeCloseTo(8.96, 2);
  const styleText = Array.from(document.querySelectorAll('style'))
    .map((style) => style.textContent ?? '')
    .join('\n');
  expect(styleText).toContain('@keyframes jump-hop-character-recoil');
  expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift');
  expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))');
  expect(styleText).toMatch(
    /data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
  );
  expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
  const advancingCharacterRule = styleText.match(
    /\.jump-hop-runtime__stage\[data-platform-advancing='true'\]\s+\.jump-hop-runtime__character\s*\{(?<body>[\s\S]*?)\}/,
  )?.groups?.body;
  expect(advancingCharacterRule).toContain('transform 120ms ease');
  expect(advancingCharacterRule).toContain('opacity 160ms ease');
  expect(advancingCharacterRule).not.toContain('left');
  expect(advancingCharacterRule).not.toContain('top');
  expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe(
    cameraLayer,
  );
  expect(
    screen
      .getByTestId('jump-hop-stage')
      .querySelector("[data-advance-state='settling']"),
  ).toBeNull();
  expect(
    screen
      .getByTestId('jump-hop-stage')
      .querySelector("[data-advance-state='entering']"),
  ).toBeNull();
  expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
    'p0',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
    'p1',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
    '64%',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
    '1.08',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
    'p2',
  );
  expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
    '47%',
  );

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

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
    'true',
  );
  expect(
    (
      screen.getByTestId('jump-hop-character-logo')
        .parentElement as HTMLElement
    ).getAttribute('data-landing-recoil'),
  ).toBe('false');

  await act(async () => {
    vi.advanceTimersByTime(660);
  });

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
    'true',
  );

  await act(async () => {
    vi.advanceTimersByTime(100);
  });

  expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
    'false',
  );
  expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
    'false',
  );
  const retainedOldPlatform = screen
    .getByTestId('jump-hop-stage')
    .querySelector("[data-platform-id='p0']") as HTMLElement | null;
  expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe(
    'exiting',
  );
  expect(retainedOldPlatform?.style.top).toBe('81%');
  const currentPlatform = screen
    .getByTestId('jump-hop-stage')
    .querySelector("[data-platform-id='p1']") as HTMLElement | null;
  expect(currentPlatform?.style.top).toBe('64%');
  expect(currentPlatform?.getAttribute('data-current')).toBe(
    'true',
  );
  expect(currentPlatform?.getAttribute('data-platform-id')).toBe(
    'p1',
  );
  expect(
    (
      screen
        .getByTestId('jump-hop-stage')
        .querySelector("[data-platform-id='p2']") as HTMLElement | null
    )?.style.top,
  ).toBe('47%');

  await act(async () => {
    dispatchPointerEvent(stage, 'pointerdown', {
      pointerId: 2,
      clientX: 180,
      clientY: 420,
    });
  });
  await act(async () => {
    await vi.advanceTimersByTimeAsync(160);
  });
  await act(async () => {
    dispatchPointerEvent(stage, 'pointerup', {
      pointerId: 2,
      clientX: 180,
      clientY: 420,
    });
  });

  expect(onJump).toHaveBeenCalledTimes(2);

  rerender(
    <JumpHopRuntimeShell
      profile={buildProfile({ tileAssets: buildTileAssets() })}
      run={runAfterSecondJump}
      onJump={onJump}
      onRestart={() => {}}
    />,
  );

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

  const movedOldPlatform = screen
    .getByTestId('jump-hop-stage')
    .querySelector("[data-platform-id='p0']") as HTMLElement | null;
  if (movedOldPlatform) {
    expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81);
  }
  expect(
    (
      screen
        .getByTestId('jump-hop-stage')
        .querySelector("[data-current='true']") as HTMLElement | null
    )?.getAttribute('data-platform-id'),
  ).toBe('p2');

  vi.useRealTimers();
});

function buildRun(): JumpHopRuntimeRunSnapshotResponse {
  return {
    runId: 'jump-hop-run-test',
    profileId: 'jump-hop-profile-test',
    ownerUserId: 'user-test',
    status: 'playing',
    currentPlatformIndex: 0,
    successfulJumpCount: 0,
    durationMs: 0,
    score: 0,
    combo: 0,
    path: {
      seed: 'test',
      difficulty: 'standard',
      finishIndex: 4294967295,
      cameraPreset: 'portrait-isometric-9x16',
      scoring: {
        chargeToDistanceRatio: 0.004,
        maxChargeMs: 900,
        hitBonus: 20,
        perfectBonus: 60,
      },
      platforms: [
        {
          platformId: 'p0',
          tileType: 'start',
          x: 0,
          y: 0,
          width: 1,
          height: 1,
          landingRadius: 0.5,
          perfectRadius: 0.2,
          scoreValue: 1,
        },
        {
          platformId: 'p1',
          tileType: 'normal',
          x: 0.8,
          y: 1.2,
          width: 1,
          height: 1,
          landingRadius: 0.5,
          perfectRadius: 0.2,
          scoreValue: 1,
        },
        {
          platformId: 'p2',
          tileType: 'target',
          x: -0.2,
          y: 2.4,
          width: 1,
          height: 1,
          landingRadius: 0.5,
          perfectRadius: 0.2,
          scoreValue: 1,
        },
      ],
    },
    lastJump: null,
    startedAtMs: 1000,
    finishedAtMs: null,
  };
}

function buildFailedRun(): JumpHopRuntimeRunSnapshotResponse {
  return {
    ...buildRun(),
    status: 'failed',
    successfulJumpCount: 8,
    durationMs: 8123,
    score: 8,
    combo: 0,
    lastJump: {
      chargeMs: 420,
      jumpDistance: 1.62,
      targetPlatformIndex: 1,
      landedX: 0,
      landedY: 0,
      result: 'miss',
    },
    finishedAtMs: 9123,
  };
}

function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
  const run = buildRun();
  return {
    ...run,
    path: {
      ...run.path,
      platforms: [
        ...run.path.platforms,
        {
          platformId: 'p3',
          tileType: 'normal',
          x: 0.5,
          y: 3.6,
          width: 1,
          height: 1,
          landingRadius: 0.5,
          perfectRadius: 0.2,
          scoreValue: 1,
        },
      ],
    },
  };
}

function buildTileAssets(options: { withFaceAssets?: boolean } = {}) {
  return Array.from({ length: 18 }, (_, index) => {
    const tileNumber = String(index + 1).padStart(2, '0');
    const atlasRow = Math.floor(index / 3) + 1;
    const atlasCol = (index % 3) + 1;
    const buildFaceAsset = (
      face: keyof NonNullable<
        JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
      >,
    ) => ({
      face,
      assetId: `asset-${tileNumber}-${face}`,
      imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
      imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
      assetObjectId: `asset-object-${tileNumber}-${face}`,
      generationProvider: 'vector-engine',
      prompt: `tile ${tileNumber} ${face}`,
      width: 256,
      height: 256,
      sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`,
    });
    const faceAssets: NonNullable<
      JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
    > = {
      top: buildFaceAsset('top'),
      front: buildFaceAsset('front'),
      right: buildFaceAsset('right'),
      back: buildFaceAsset('back'),
      left: buildFaceAsset('left'),
      bottom: buildFaceAsset('bottom'),
    };
    return {
      tileType: index === 0 ? 'start' : 'normal',
      tileId: `tile-${tileNumber}`,
      imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
      imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
      assetObjectId: `asset-object-${tileNumber}`,
      sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`,
      atlasRow,
      atlasCol,
      visualWidth: 256,
      visualHeight: 256,
      topSurfaceRadius: 42,
      landingRadius: 34,
      faceAssets: options.withFaceAssets ? faceAssets : undefined,
    } satisfies JumpHopWorkProfileResponse['tileAssets'][number];
  });
}

function buildProfile(options: {
  tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
  coverComposite?: string | null;
  coverImageSrc?: string | null;
  backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset'];
  publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
} = {}): JumpHopWorkProfileResponse {
  const characterAsset = {
    assetId: 'builtin',
    imageSrc: 'builtin://jump-hop/default-character',
    imageObjectKey: '',
    assetObjectId: 'builtin',
    generationProvider: 'builtin-three',
    prompt: '默认角色',
    width: 0,
    height: 0,
  };
  return {
    summary: {
      runtimeKind: 'jump-hop',
      workId: 'jump-hop-profile-test',
      profileId: 'jump-hop-profile-test',
      ownerUserId: 'user-test',
      sourceSessionId: 'jump-hop-session-test',
      themeText: '测试',
      workTitle: '测试',
      workDescription: '测试',
      themeTags: ['测试'],
      difficulty: 'standard',
      stylePreset: 'minimal-blocks',
      coverImageSrc: options.coverImageSrc ?? null,
      publicationStatus: options.publicationStatus ?? 'draft',
      playCount: 0,
      updatedAt: '2026-05-27T00:00:00Z',
      publishedAt: null,
      publishReady: true,
      generationStatus: 'ready',
    },
    draft: {
      templateId: 'jump-hop',
      templateName: '跳一跳',
      profileId: 'jump-hop-profile-test',
      themeText: '测试',
      workTitle: '测试',
      workDescription: '测试',
      themeTags: ['测试'],
      difficulty: 'standard',
      stylePreset: 'minimal-blocks',
      defaultCharacter: {
        characterId: 'jump-hop-default-runner',
        displayName: '默认角色',
        modelKind: 'builtin-three',
        bodyColor: '#f59e0b',
        accentColor: '#2563eb',
      },
      characterPrompt: '默认角色',
      tilePrompt: '地块',
      endMoodPrompt: null,
      characterAsset,
      tileAtlasAsset: characterAsset,
      tileAssets: options.tileAssets ?? [],
      path: buildRun().path,
      coverComposite: options.coverComposite ?? null,
      backButtonAsset: options.backButtonAsset ?? null,
      generationStatus: 'ready',
    },
    path: buildRun().path,
    defaultCharacter: {
      characterId: 'jump-hop-default-runner',
      displayName: '默认角色',
      modelKind: 'builtin-three',
      bodyColor: '#f59e0b',
      accentColor: '#2563eb',
    },
    characterAsset,
    tileAtlasAsset: characterAsset,
    tileAssets: options.tileAssets ?? [],
    backButtonAsset: options.backButtonAsset ?? null,
  };
}

test('跳一跳运行态使用 image2 背景底图铺满舞台底层', () => {
  const backgroundSource =
    '/generated-jump-hop-assets/jump-hop-profile-test/background/image.png';

  render(
    <JumpHopRuntimeShell
      profile={buildProfile({ coverComposite: backgroundSource })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  const backgroundImage = screen.getByTestId('jump-hop-stage-background-image');
  expect(backgroundImage.getAttribute('src')).toBe(backgroundSource);
  const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
  expect(backdrop?.getAttribute('data-has-background')).toBe('true');
  expect(useResolvedAssetReadUrl).toHaveBeenCalledWith(
    backgroundSource,
    expect.objectContaining({
      refreshKey: backgroundSource,
    }),
  );
});

test('跳一跳运行态忽略旧 cover composite 占位背景', () => {
  render(
    <JumpHopRuntimeShell
      profile={buildProfile({
        coverComposite:
          '/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png',
      })}
      run={buildRun()}
      onJump={vi.fn().mockResolvedValue(undefined)}
      onRestart={() => {}}
    />,
  );

  expect(screen.queryByTestId('jump-hop-stage-background-image')).toBeNull();
  const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
  expect(backdrop?.getAttribute('data-has-background')).toBe('false');
});
