import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
import { VideoWorkspace } from './VideoWorkspace';

const apiMock = vi.hoisted(() => ({
  getProjectFrames: vi.fn(),
  parseMedia: vi.fn(),
  propagateMasks: vi.fn(),
  queuePropagationTask: vi.fn(),
  getTask: vi.fn(),
  cancelTask: vi.fn(),
  getTemplates: vi.fn(),
  getProjectAnnotations: vi.fn(),
  saveAnnotation: vi.fn(),
  updateAnnotation: vi.fn(),
  deleteAnnotation: vi.fn(),
  exportCoco: vi.fn(),
  exportMasks: vi.fn(),
  exportSegmentationResults: vi.fn(),
  importGtMask: vi.fn(),
  annotationToMask: vi.fn(),
  buildAnnotationPayload: vi.fn(),
  getAiModelStatus: vi.fn(),
  analyzeMask: vi.fn(),
}));

vi.mock('../lib/api', () => ({
  getProjectFrames: apiMock.getProjectFrames,
  parseMedia: apiMock.parseMedia,
  propagateMasks: apiMock.propagateMasks,
  queuePropagationTask: apiMock.queuePropagationTask,
  getTask: apiMock.getTask,
  cancelTask: apiMock.cancelTask,
  getTemplates: apiMock.getTemplates,
  getProjectAnnotations: apiMock.getProjectAnnotations,
  saveAnnotation: apiMock.saveAnnotation,
  updateAnnotation: apiMock.updateAnnotation,
  deleteAnnotation: apiMock.deleteAnnotation,
  exportCoco: apiMock.exportCoco,
  exportMasks: apiMock.exportMasks,
  exportSegmentationResults: apiMock.exportSegmentationResults,
  importGtMask: apiMock.importGtMask,
  annotationToMask: apiMock.annotationToMask,
  buildAnnotationPayload: apiMock.buildAnnotationPayload,
  getAiModelStatus: apiMock.getAiModelStatus,
  analyzeMask: apiMock.analyzeMask,
}));

describe('VideoWorkspace', () => {
  beforeEach(() => {
    resetStore();
    vi.clearAllMocks();
    useStore.setState({ currentProject: { id: '1', name: 'Demo', status: 'ready', video_path: 'uploads/demo.mp4' } });
    apiMock.getTemplates.mockResolvedValue([]);
    apiMock.getProjectAnnotations.mockImplementation(async () => (
      useStore.getState().masks
        .filter((mask) => mask.annotationId)
        .map((mask) => ({ id: Number(mask.annotationId), frame_id: Number(mask.frameId) || mask.frameId }))
    ));
    apiMock.annotationToMask.mockReturnValue(null);
    apiMock.queuePropagationTask.mockResolvedValue({ id: 31, status: 'queued', progress: 0, message: '自动传播任务已入队' });
    apiMock.getTask.mockResolvedValue({
      id: 31,
      status: 'success',
      progress: 100,
      message: '自动传播完成',
      result: { processed_frame_count: 3, created_annotation_count: 2, completed_steps: 1 },
    });
    apiMock.cancelTask.mockResolvedValue({ id: 31, status: 'cancelled', progress: 100, message: '任务已取消' });
    apiMock.propagateMasks.mockResolvedValue({
      model: 'sam2.1_hiera_tiny',
      direction: 'forward',
      source_frame_id: 10,
      processed_frame_count: 3,
      created_annotation_count: 2,
      annotations: [],
    });
    apiMock.getAiModelStatus.mockResolvedValue({
      selected_model: 'sam2.1_hiera_tiny',
      gpu: { available: false, device: 'cpu', name: null, torch_available: true },
      models: [
        { id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: [], message: 'ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
      ],
    });
    apiMock.analyzeMask.mockResolvedValue({
      confidence: 0.7,
      confidence_source: 'model_score',
      topology_anchor_count: 0,
      topology_anchors: [],
      area: 0.1,
      bbox: [0, 0, 0.1, 0.1],
      source: 'test',
      message: 'ok',
    });
  });

  it('loads project frames into the workspace store', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);

    await waitFor(() => expect(useStore.getState().frames).toEqual([
      { id: '10', projectId: '1', index: 0, url: '/frame.jpg', width: 640, height: 360 },
    ]));
    expect(screen.getByText('Demo')).toBeInTheDocument();
    expect(apiMock.parseMedia).not.toHaveBeenCalled();
    expect(apiMock.getProjectAnnotations).toHaveBeenCalledWith('1');
  });

  it('exposes workspace undo/redo buttons and keyboard shortcuts without hijacking inputs', async () => {
    const mask = {
      id: 'mask-undo',
      frameId: '10',
      pathData: 'M 0 0 Z',
      label: 'Draft',
      color: '#06b6d4',
    };
    useStore.setState({
      currentProject: null,
      masks: [mask],
      maskHistory: [[]],
      maskFuture: [],
    });

    render(<VideoWorkspace />);
    const undoButton = screen.getByRole('button', { name: '撤销操作' });
    const redoButton = screen.getByRole('button', { name: '重做操作' });
    expect(undoButton.querySelector('svg')).toHaveClass('text-amber-300');
    expect(redoButton.querySelector('svg')).toHaveClass('text-indigo-300');

    fireEvent.click(undoButton);

    expect(useStore.getState().masks).toEqual([]);

    fireEvent.click(redoButton);
    expect(useStore.getState().masks).toEqual([mask]);

    fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([]);

    fireEvent.keyDown(window, { key: 'z', ctrlKey: true, shiftKey: true });
    expect(useStore.getState().masks).toEqual([mask]);

    fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([]);

    fireEvent.keyDown(window, { key: 'y', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([mask]);

    fireEvent.keyDown(window, { key: 'Process', code: 'KeyZ', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([]);

    fireEvent.keyDown(window, { key: 'Process', code: 'KeyY', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([mask]);

    fireEvent.keyDown(screen.getByLabelText('遮罩透明度'), { key: 'z', ctrlKey: true });
    expect(useStore.getState().masks).toEqual([mask]);
  });

  it('auto-dismisses short workspace operation messages without blocking later actions', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));

    vi.useFakeTimers();
    fireEvent.click(screen.getByRole('button', { name: '已全部保存' }));
    expect(screen.getByText('没有待保存标注')).toBeInTheDocument();

    act(() => {
      vi.advanceTimersByTime(3600);
    });

    expect(screen.queryByText('没有待保存标注')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: '已全部保存' })).not.toBeDisabled();
    vi.useRealTimers();
  });

  it('does not auto-generate frames when a media project has no frames yet', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([]);

    render(<VideoWorkspace />);

    await waitFor(() => expect(apiMock.getProjectFrames).toHaveBeenCalledWith('1'));
    expect(apiMock.parseMedia).not.toHaveBeenCalled();
    expect(apiMock.getTask).not.toHaveBeenCalled();
    expect(useStore.getState().frames).toEqual([]);
    expect(await screen.findByText('该项目已导入视频但尚未生成帧，请在项目库点击“生成帧”')).toBeInTheDocument();
  });

  it('hydrates saved annotations after loading frames', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
    apiMock.annotationToMask.mockReturnValueOnce({
      id: 'annotation-99',
      annotationId: '99',
      frameId: '10',
      saved: true,
      pathData: 'M 0 0 Z',
      label: 'Saved',
      color: '#06b6d4',
    });

    render(<VideoWorkspace />);

    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-99', saved: true }),
    ]));
  });

  it('downgrades masks whose saved class no longer exists in the template to maskid 0 pending classification', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 100, frame_id: 10, template_id: 2 }]);
    apiMock.annotationToMask.mockReturnValueOnce({
      id: 'annotation-100',
      annotationId: '100',
      frameId: '10',
      templateId: '2',
      classId: 'deleted-class',
      className: '已删除类别',
      classMaskId: 7,
      saved: true,
      saveStatus: 'saved',
      pathData: 'M 0 0 Z',
      label: '已删除类别',
      color: '#ff0000',
      segmentation: [[0, 0, 10, 0, 10, 10]],
    });
    useStore.setState({
      templates: [{
        id: '2',
        name: '当前模板',
        classes: [{ id: 'c1', name: '胆囊', color: '#00ff00', zIndex: 10, maskId: 1 }],
        rules: [],
      }],
    });

    render(<VideoWorkspace />);

    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({
        id: 'annotation-100',
        label: '待分类',
        className: '待分类',
        classMaskId: 0,
        classId: undefined,
        color: '#9ca3af',
        saved: false,
        saveStatus: 'dirty',
        metadata: expect.objectContaining({
          needs_classification: true,
          stale_class: expect.objectContaining({ id: 'deleted-class', maskId: 7 }),
        }),
      }),
    ]));
    expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
  });

  it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
    apiMock.annotationToMask.mockReturnValueOnce({
      id: 'annotation-99',
      annotationId: '99',
      frameId: '10',
      saved: true,
      pathData: 'M 0 0 Z',
      label: 'Saved',
      color: '#06b6d4',
    });
    useStore.setState({
      activeTool: 'edit_polygon',
      selectedMaskIds: ['ai-mask'],
      masks: [{
        id: 'ai-mask',
        frameId: '10',
        pathData: 'M 10 10 L 40 10 L 40 40 Z',
        label: 'AI Mask',
        color: '#06b6d4',
        segmentation: [[10, 10, 40, 10, 40, 40]],
        saveStatus: 'draft',
        saved: false,
        metadata: { source: 'ai_segmentation' },
      }],
    });

    render(<VideoWorkspace />);

    await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual([
      'ai-mask',
      'annotation-99',
    ]));
    expect(useStore.getState().selectedMaskIds).toEqual(['ai-mask']);
    expect(useStore.getState().activeTool).toBe('edit_polygon');
  });

  it('keeps the current non-first frame when returning from AI segmentation to the workspace', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-2.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-3.jpg', width: 640, height: 360 },
    ]);
    useStore.setState({
      frames: [
        { id: '10', projectId: '1', index: 0, url: '/frame-1.jpg', width: 640, height: 360 },
        { id: '11', projectId: '1', index: 1, url: '/frame-2.jpg', width: 640, height: 360 },
        { id: '12', projectId: '1', index: 2, url: '/frame-3.jpg', width: 640, height: 360 },
      ],
      currentFrameIndex: 1,
      activeTool: 'edit_polygon',
      selectedMaskIds: ['ai-mask-frame-2'],
      masks: [{
        id: 'ai-mask-frame-2',
        frameId: '11',
        pathData: 'M 10 10 L 40 10 L 40 40 Z',
        label: 'AI Mask',
        color: '#06b6d4',
        segmentation: [[10, 10, 40, 10, 40, 40]],
        saveStatus: 'draft',
        saved: false,
        metadata: { source: 'ai_segmentation' },
      }],
    });

    render(<VideoWorkspace />);

    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    expect(useStore.getState().currentFrameIndex).toBe(1);
    expect(useStore.getState().selectedMaskIds).toEqual(['ai-mask-frame-2']);
  });

  it('saves pending masks through the archive button', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 5, frame_id: 10 }]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
    apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
    apiMock.annotationToMask.mockReturnValueOnce({
      id: 'annotation-5',
      annotationId: '5',
      frameId: '10',
      saved: true,
      saveStatus: 'saved',
      pathData: 'M 0 0 Z',
      label: 'Saved AI Mask',
      color: '#06b6d4',
    });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        activeTemplateId: '2',
        masks: [{
          id: 'mask-1',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: 'AI Mask',
          color: '#06b6d4',
          segmentation: [[0, 0, 10, 0, 10, 10]],
          bbox: [0, 0, 10, 10],
        }],
      });
    });

    expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));

    await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
      project_id: 1,
      frame_id: 10,
      mask_data: { polygons: [] },
    }));
    expect(apiMock.buildAnnotationPayload).toHaveBeenCalledWith(
      '1',
      expect.objectContaining({ id: 'mask-1' }),
      expect.objectContaining({ id: '10' }),
      '2',
    );
    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-5', saved: true, saveStatus: 'saved' }),
    ]));
    expect(useStore.getState().masks.some((mask) => mask.id === 'mask-1')).toBe(false);
    expect(screen.getByRole('button', { name: '已全部保存' })).toBeInTheDocument();
  });

  it('updates dirty saved masks through the archive button', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 99, frame_id: 10 }])
      .mockResolvedValueOnce([]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: { polygons: [], label: '胆囊' },
    });
    apiMock.updateAnnotation.mockResolvedValueOnce({ id: 99 });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        activeTemplateId: '2',
        masks: [{
          id: 'annotation-99',
          annotationId: '99',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          saveStatus: 'dirty',
          segmentation: [[0, 0, 10, 0, 10, 10]],
          bbox: [0, 0, 10, 10],
          metadata: {
            source: 'sam2.1_hiera_tiny_propagation',
            propagated_from_frame_id: 10,
            propagation_seed_key: 'annotation:7',
            source_annotation_id: 7,
            source_mask_id: 'annotation-7',
            propagation_seed_signature: 'old-signature',
            geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
          },
        }],
      });
    });

    expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));

    await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', {
      template_id: 2,
      mask_data: {
        polygons: [],
        label: '胆囊',
        source: 'sam2.1_hiera_tiny_propagation',
        propagated_from_frame_id: 10,
        propagation_seed_key: 'annotation:7',
        source_annotation_id: 7,
        source_mask_id: 'annotation-7',
        propagation_seed_signature: 'old-signature',
      },
      points: undefined,
      bbox: undefined,
    }));
    expect(apiMock.updateAnnotation.mock.calls[0][1].mask_data).not.toHaveProperty('geometry_smoothing_preview');
    expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
  });

  it('recreates a dirty local annotation without PATCH when the backend preflight says it is missing', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 120, frame_id: 10 }]);
    apiMock.annotationToMask.mockImplementation((annotation) => ({
      id: `annotation-${annotation.id}`,
      annotationId: String(annotation.id),
      frameId: String(annotation.frame_id),
      pathData: 'M 0 0 Z',
      label: '胆囊',
      color: '#ff0000',
      saved: true,
      saveStatus: 'saved',
      segmentation: [[0, 0, 10, 0, 10, 10]],
    }));
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: { polygons: [], label: '胆囊' },
    });
    apiMock.saveAnnotation.mockResolvedValueOnce({ id: 120 });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        activeTemplateId: '2',
        masks: [{
          id: 'annotation-11859',
          annotationId: '11859',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          saveStatus: 'dirty',
          segmentation: [[0, 0, 10, 0, 10, 10]],
          metadata: {
            source: 'sam2.1_hiera_tiny_propagation',
            propagation_seed_key: 'annotation:7',
            source_annotation_id: 7,
          },
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));

    await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
    expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
    expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: {
        polygons: [],
        label: '胆囊',
        source: 'sam2.1_hiera_tiny_propagation',
        propagation_seed_key: 'annotation:7',
        source_annotation_id: 7,
      },
    });
    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-120', annotationId: '120', saveStatus: 'saved' }),
    ]));
    expect(screen.getByText('已保存 1 个标注，其中 1 个本地旧标注已重新创建')).toBeInTheDocument();
  });

  it('recreates a dirty local annotation if the backend PATCH target disappears after preflight', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 11859, frame_id: 10 }])
      .mockResolvedValueOnce([{ id: 121, frame_id: 10 }]);
    apiMock.annotationToMask.mockImplementation((annotation) => ({
      id: `annotation-${annotation.id}`,
      annotationId: String(annotation.id),
      frameId: String(annotation.frame_id),
      pathData: 'M 0 0 Z',
      label: '胆囊',
      color: '#ff0000',
      saved: true,
      saveStatus: 'saved',
      segmentation: [[0, 0, 10, 0, 10, 10]],
    }));
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: { polygons: [], label: '胆囊' },
    });
    apiMock.updateAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
    apiMock.saveAnnotation.mockResolvedValueOnce({ id: 121 });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        activeTemplateId: '2',
        masks: [{
          id: 'annotation-11859',
          annotationId: '11859',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          saveStatus: 'dirty',
          segmentation: [[0, 0, 10, 0, 10, 10]],
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));

    await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('11859', expect.objectContaining({
      template_id: 2,
    })));
    expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: { polygons: [], label: '胆囊' },
    });
    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-121', annotationId: '121', saveStatus: 'saved' }),
    ]));
  });

  it('deletes saved annotations when clearing current-frame masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValueOnce(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-99',
            annotationId: '99',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Saved',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'draft-1',
            frameId: '10',
            pathData: 'M 1 1 Z',
            label: 'Draft',
            color: '#ff0000',
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));

    expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
    expect(useStore.getState().masks).toEqual([]);
  });

  it('clears linked propagated masks when clearing current-frame masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Seed',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
        selectedMaskIds: ['annotation-10'],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    expect(screen.getByText('选择清空范围')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' }));
    expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '是，删除人工帧' }));

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
    expect(useStore.getState().masks).toEqual([]);
    expect(useStore.getState().selectedMaskIds).toEqual([]);
  });

  it('can keep manual frames when clearing all propagated masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Seed',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' }));
    expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '否，保留人工帧' }));

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
    expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
  });

  it('can clear only the current frame when current masks have propagated results', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Seed',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '只清当前帧' }));

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
    expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-10' }),
    ]);
  });

  it('clears only selected current-frame masks when a selected mask has no propagated results', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
          { id: 'annotation-2', annotationId: '2', frameId: '10', pathData: 'M 2 2 Z', label: 'Standalone', color: '#ff0000', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
        selectedMaskIds: ['annotation-2'],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));

    expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2'));
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
    expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-1' }),
      expect.objectContaining({ id: 'annotation-10' }),
    ]);
  });

  it('can cancel current-frame propagated clear confirmation', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '取消' }));

    expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
    expect(useStore.getState().masks).toHaveLength(2);
    expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
  });

  it('offers current, range, and all propagation clear choices in one confirmation', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'seed-1',
            annotationId: '98',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Seed',
            color: '#ef4444',
            saved: true,
            saveStatus: 'saved',
            metadata: { source_annotation_id: 98, source_mask_id: 'annotation-98' },
          },
          {
            id: 'propagated-1',
            annotationId: '99',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Seed',
            color: '#ef4444',
            saved: true,
            saveStatus: 'saved',
            metadata: { source_annotation_id: 98, source_mask_id: 'annotation-98' },
          },
        ],
        selectedMaskIds: ['seed-1'],
      });
    });

    expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument();
    fireEvent.click(screen.getByTitle('清空遮罩'));
    expect(screen.getByText('选择清空范围')).toBeInTheDocument();
    const rangeButton = screen.getByRole('button', { name: '按帧范围选择' });
    expect(rangeButton).toBeInTheDocument();
    expect(rangeButton.parentElement).toHaveClass('grid-cols-4');
    expect(screen.getByRole('button', { name: '取消' }).parentElement).toBe(rangeButton.parentElement);
    expect(screen.getByRole('button', { name: '只清当前帧' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '清空所有传播帧' })).toBeInTheDocument();
  });

  it('clears propagated masks only within the selected frame range', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: 'Seed',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '12',
            pathData: 'M 2 2 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
    expect(screen.getByText('请选择清空传播帧范围，再点击“确认清空”')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
    fireEvent.click(screen.getByRole('button', { name: '确认清空' }));

    expect(screen.getByText(/清空同一传播链上的 1 个遮罩/)).toBeInTheDocument();
    const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
    fireEvent.click(confirmButtons[confirmButtons.length - 1]);

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('20');
    expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
  });

  it('can keep frames with manual masks when clearing a propagated frame range', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          {
            id: 'annotation-11',
            annotationId: '11',
            frameId: '11',
            pathData: 'M 4 4 Z',
            label: 'Manual edit',
            color: '#f97316',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '12',
            pathData: 'M 2 2 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
    fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
    fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
    const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
    fireEvent.click(confirmButtons[confirmButtons.length - 1]);
    expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '否，保留人工帧' }));

    await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧，本次没有其它可清空的自动传播遮罩')).toBeInTheDocument());
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
    expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-20']);
  });

  it('asks about manual-only frames inside the selected clear range before clearing propagated masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-11',
            annotationId: '11',
            frameId: '11',
            pathData: 'M 4 4 Z',
            label: 'Manual edit',
            color: '#f97316',
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '12',
            pathData: 'M 2 2 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
    fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
    fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
    const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
    fireEvent.click(confirmButtons[confirmButtons.length - 1]);

    expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '否，保留人工帧' }));

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20'));
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('11');
    expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-11']);
  });

  it('deletes all masks on manual frames when the user confirms manual-frame clearing', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          { id: 'annotation-11', annotationId: '11', frameId: '11', pathData: 'M 4 4 Z', label: 'Manual edit', color: '#f97316', saved: true, saveStatus: 'saved' },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '12',
            pathData: 'M 2 2 Z',
            label: 'Propagated',
            color: '#06b6d4',
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
        ],
      });
    });

    fireEvent.click(screen.getByTitle('清空遮罩'));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
    fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
    fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
    const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
    fireEvent.click(confirmButtons[confirmButtons.length - 1]);
    fireEvent.click(screen.getByRole('button', { name: '是，删除人工帧' }));

    await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('11');
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
    expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
  });

  it('auto-saves pending masks before exporting segmentation results', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
    apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
    apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'mask-1',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: 'AI Mask',
          color: '#06b6d4',
          segmentation: [[0, 0, 10, 0, 10, 10]],
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
    fireEvent.change(screen.getByLabelText('Mix_label 遮罩透明度'), { target: { value: '0.45' } });
    fireEvent.click(screen.getByRole('button', { name: '开始导出' }));

    await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
    expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
      scope: 'current',
      outputs: ['separate', 'gt_label', 'pro_label', 'mix_label'],
      mixOpacity: 0.45,
      startFrame: undefined,
      endFrame: undefined,
      frameId: '10',
    });
  });

  it('exports a selected frame range with GT label masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360, timestamp_ms: 0, source_frame_number: 0 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360, timestamp_ms: 500, source_frame_number: 15 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360, timestamp_ms: 1000, source_frame_number: 30 },
    ]);
    apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
    const downloads: string[] = [];
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function mockClick(this: HTMLAnchorElement) {
      downloads.push(this.download);
    });
    useStore.setState({ currentProject: { id: '1', name: '病例 A/1', status: 'ready', video_path: 'uploads/demo.mp4' } });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));

    fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
    fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
    fireEvent.change(screen.getByLabelText('导出起始帧'), { target: { value: '2' } });
    fireEvent.change(screen.getByLabelText('导出结束帧'), { target: { value: '3' } });
    fireEvent.click(screen.getByRole('button', { name: '分开 Mask' }));
    fireEvent.click(screen.getByRole('button', { name: 'Pro_label 彩色' }));
    fireEvent.click(screen.getByRole('button', { name: 'Mix_label 叠加' }));
    fireEvent.click(screen.getByRole('button', { name: '开始导出' }));

    await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
      scope: 'range',
      outputs: ['gt_label'],
      mixOpacity: 0.3,
      startFrame: 2,
      endFrame: 3,
      frameId: undefined,
    }));
    expect(downloads[0]).toBe('病例_A_1_seg_T_0h00m00s500ms-0h00m01s000ms_P_2-3.zip');
    clickSpy.mockRestore();
  });

  it('lets the timeline range picker update selected frame export bounds', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
      { id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
      { id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
    ]);
    apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));

    fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
    fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
    expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧，也可直接修改导出范围')).toBeInTheDocument();

    const processingBar = screen.getByLabelText('视频处理进度条');
    vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
      left: 0,
      right: 100,
      top: 0,
      bottom: 10,
      width: 100,
      height: 10,
      x: 0,
      y: 0,
      toJSON: () => ({}),
    });
    fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
    fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
    fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });

    expect(screen.getByLabelText('导出起始帧')).toHaveValue(2);
    expect(screen.getByLabelText('导出结束帧')).toHaveValue(5);

    fireEvent.click(screen.getByRole('button', { name: '开始导出' }));

    await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
      scope: 'range',
      outputs: ['separate', 'gt_label', 'pro_label', 'mix_label'],
      mixOpacity: 0.3,
      startFrame: 2,
      endFrame: 5,
      frameId: undefined,
    }));
  });

  it('switches from export range selection to propagation range selection without starting propagation immediately', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'annotation-8',
          annotationId: '8',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
          saveStatus: 'saved',
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
    fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));

    expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧，再点击“开始传播”')).toBeInTheDocument();
    expect(screen.getAllByText('SAM 2.1 Tiny').length).toBeGreaterThan(0);
    expect(screen.getByText('向前 0 帧')).toBeInTheDocument();
    expect(screen.getByText('向后 2 帧')).toBeInTheDocument();
    expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
  });

  it('uses the timeline frame range selector for propagated boolean operations', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-2',
            annotationId: '2',
            frameId: '10',
            pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '11',
            pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
          },
          {
            id: 'annotation-11',
            annotationId: '11',
            frameId: '12',
            pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          {
            id: 'annotation-21',
            annotationId: '21',
            frameId: '12',
            pathData: 'M 54 54 L 104 54 L 104 104 L 54 104 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[54, 54, 104, 54, 104, 104, 54, 104]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
          },
        ],
        activeTool: 'area_merge',
      });
    });
    apiMock.getProjectAnnotations.mockResolvedValue([
      { id: 1, frame_id: 10 },
      { id: 2, frame_id: 10 },
      { id: 10, frame_id: 11 },
      { id: 20, frame_id: 11 },
      { id: 11, frame_id: 12 },
      { id: 21, frame_id: 12 },
    ]);

    const paths = await screen.findAllByTestId('konva-path');
    fireEvent.click(paths[0]);
    fireEvent.click(paths[1]);
    fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));

    expect(screen.getByText('请选择区域合并帧范围，再点击“确认区域合并”')).toBeInTheDocument();
    expect(screen.getAllByTestId('propagation-range-overlay')).toHaveLength(2);
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
    fireEvent.click(screen.getByRole('button', { name: '确认区域合并' }));
    expect(screen.getByText(/对 2 帧存在对应传播链的区域执行区域合并/)).toBeInTheDocument();
    const confirmButtons = screen.getAllByRole('button', { name: '确认区域合并' });
    fireEvent.click(confirmButtons[confirmButtons.length - 1]);

    await waitFor(() => {
      const ids = useStore.getState().masks.map((mask) => mask.id).sort();
      expect(ids).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-21']);
    });
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2');
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('21');
    expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
      saveStatus: 'dirty',
      metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation' }),
    }));
  });

  it('cancels a pending boolean frame range when the user starts a new current-frame merge', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-1',
            annotationId: '1',
            frameId: '10',
            pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-2',
            annotationId: '2',
            frameId: '10',
            pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
            saved: true,
            saveStatus: 'saved',
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
          },
          {
            id: 'annotation-20',
            annotationId: '20',
            frameId: '11',
            pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
            label: 'A',
            color: '#06b6d4',
            segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
            saved: true,
            saveStatus: 'saved',
            metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
          },
        ],
        activeTool: 'area_merge',
      });
    });
    apiMock.getProjectAnnotations.mockResolvedValue([
      { id: 1, frame_id: 10 },
      { id: 2, frame_id: 10 },
      { id: 10, frame_id: 11 },
      { id: 20, frame_id: 11 },
    ]);

    const paths = await screen.findAllByTestId('konva-path');
    fireEvent.click(paths[0]);
    fireEvent.click(paths[1]);
    fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
    fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
    expect(screen.getByRole('button', { name: '确认区域合并' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
    expect(screen.queryByRole('button', { name: '确认区域合并' })).not.toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '只处理当前帧' }));

    await waitFor(() => {
      const ids = useStore.getState().masks.map((mask) => mask.id).sort();
      expect(ids).toEqual(['annotation-1', 'annotation-10', 'annotation-20']);
    });
    expect(screen.queryByRole('button', { name: '确认区域合并' })).not.toBeInTheDocument();
    expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2');
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('20');
  });

  it('exports only the current frame when current image scope is selected', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({ currentFrameIndex: 1 });
    });

    fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
    fireEvent.click(screen.getByRole('button', { name: '当前图片' }));
    fireEvent.click(screen.getByRole('button', { name: 'GT_label 黑白' }));
    fireEvent.click(screen.getByRole('button', { name: 'Pro_label 彩色' }));
    fireEvent.click(screen.getByRole('button', { name: 'Mix_label 叠加' }));
    fireEvent.click(screen.getByRole('button', { name: '开始导出' }));

    await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
      scope: 'current',
      outputs: ['separate'],
      mixOpacity: 0.3,
      startFrame: undefined,
      endFrame: undefined,
      frameId: '11',
    }));
  });

  it('imports a GT mask for the current frame and hydrates saved annotations', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.importGtMask.mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
    apiMock.annotationToMask.mockReturnValueOnce({
      id: 'annotation-88',
      annotationId: '88',
      frameId: '10',
      saved: true,
      pathData: 'M 0 0 Z',
      label: 'GT Mask',
      color: '#22c55e',
    });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));

    const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(['mask'], 'mask.png', { type: 'image/png' });
    fireEvent.change(fileInput, { target: { files: [file] } });
    expect(screen.getByText('导入结果预览')).toBeInTheDocument();
    await waitFor(() => expect(screen.getByRole('button', { name: '导入为未定义' })).not.toBeDisabled());
    fireEvent.click(screen.getByRole('button', { name: '导入为未定义' }));

    await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10', null, {
      unknownColorPolicy: 'undefined',
    }));
    await waitFor(() => expect(useStore.getState().masks).toEqual([
      expect.objectContaining({ id: 'annotation-88', label: 'GT Mask' }),
    ]));
  });

  it('lets users discard unknown GT mask classes before importing', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
    ]);
    apiMock.importGtMask.mockResolvedValueOnce([]);
    apiMock.getProjectAnnotations.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
    useStore.setState({ activeTemplateId: '2' });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));

    const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(['mask'], 'color-mask.png', { type: 'image/png' });
    fireEvent.change(fileInput, { target: { files: [file] } });
    expect(screen.getByText('导入结果预览')).toBeInTheDocument();
    await waitFor(() => expect(screen.getByRole('button', { name: '舍弃未知类别' })).not.toBeDisabled());
    fireEvent.click(screen.getByRole('button', { name: '舍弃未知类别' }));

    await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10', '2', {
      unknownColorPolicy: 'discard',
    }));
  });

  it('blocks propagation with a clear message when the current reference frame has no masks', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'stale-other-frame',
          annotationId: '10369',
          frameId: '11',
          pathData: 'M 0 0 Z',
          label: '旧帧遮罩',
          color: '#ff0000',
          saveStatus: 'dirty',
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    expect(await screen.findByText('当前参考帧无遮罩')).toBeInTheDocument();
    expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
    expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
    expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
  });

  it('only saves masks on the current reference frame before propagation', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 8, frame_id: 10 }])
      .mockResolvedValue([{ id: 8, frame_id: 10 }]);
    apiMock.annotationToMask.mockImplementation((annotation) => ({
      id: `annotation-${annotation.id}`,
      annotationId: String(annotation.id),
      frameId: String(annotation.frame_id),
      pathData: 'M 0 0 Z',
      label: '胆囊',
      color: '#ff0000',
      saved: true,
      saveStatus: 'saved',
      segmentation: [[64, 36, 192, 36, 192, 108]],
      bbox: [64, 36, 128, 72],
    }));
    apiMock.buildAnnotationPayload.mockReturnValue({
      project_id: 1,
      frame_id: 10,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    });
    apiMock.updateAnnotation.mockResolvedValueOnce({ id: 8 });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [
          {
            id: 'annotation-8',
            annotationId: '8',
            frameId: '10',
            pathData: 'M 0 0 Z',
            label: '胆囊',
            color: '#ff0000',
            segmentation: [[64, 36, 192, 36, 192, 108]],
            bbox: [64, 36, 128, 72],
            saveStatus: 'dirty',
          },
          {
            id: 'stale-other-frame',
            annotationId: '10369',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: '旧帧遮罩',
            color: '#00ff00',
            segmentation: [[10, 10, 20, 10, 20, 20]],
            bbox: [10, 10, 10, 10],
            saveStatus: 'dirty',
          },
        ],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledTimes(1));
    expect(apiMock.updateAnnotation).toHaveBeenCalledWith('8', expect.any(Object));
    expect(apiMock.updateAnnotation).not.toHaveBeenCalledWith('10369', expect.any(Object));
    await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
  });

  it('auto-propagates reference-frame masks through the configured frame range', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    const seedPayload = {
      project_id: 1,
      frame_id: 10,
      template_id: 2,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
        class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
        geometry_smoothing: { strength: 35, method: 'chaikin' },
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    };
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 5, frame_id: 10 }])
      .mockResolvedValue([
        { id: 5, frame_id: 10 },
        { id: 6, frame_id: 11 },
      ]);
    apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
    apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
    apiMock.annotationToMask.mockImplementation((annotation) => ({
      id: `annotation-${annotation.id}`,
      annotationId: String(annotation.id),
      frameId: String(annotation.frame_id),
      saved: true,
      saveStatus: 'saved',
      pathData: 'M 0 0 Z',
      label: '胆囊',
      color: '#ff0000',
      segmentation: [[64, 36, 192, 36, 192, 108]],
      bbox: [64, 36, 128, 72],
      metadata: annotation.frame_id === 11
        ? { source: 'sam2.1_hiera_tiny_propagation', propagated_from_frame_id: 10, source_annotation_id: 5 }
        : undefined,
    }));

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        aiModel: 'sam2.1_hiera_tiny',
        activeTemplateId: '2',
        masks: [{
          id: 'mask-1',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
          metadata: {
            source: 'sam2.1_hiera_tiny_propagation',
            source_annotation_id: 5,
            source_mask_id: 'annotation-5',
            propagation_seed_signature: 'seed-signature-5',
            geometry_smoothing: { strength: 35, method: 'chaikin' },
          },
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledTimes(1));
    await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith({
      project_id: 1,
      frame_id: 10,
      model: 'sam2.1_hiera_tiny',
      include_source: false,
      save_annotations: true,
      steps: [{
        direction: 'forward',
        max_frames: 2,
        seed: {
          polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
          bbox: [0.1, 0.1, 0.2, 0.2],
          points: undefined,
          label: '胆囊',
          color: '#ff0000',
          class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
          template_id: 2,
          source_mask_id: 'annotation-5',
          source_annotation_id: 5,
          smoothing: { strength: 35, method: 'chaikin' },
        },
      }],
    }));
    await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask，处理 3 帧次，删除旧区域 0 个，保存 2 个区域')).toBeInTheDocument());
    expect(screen.getByTestId('propagation-history-segment')).toHaveAttribute('title', 'SAM 2.1 Tiny 自动传播：第 1-2 帧');
  });

  it('uses the separately selected propagation weight when queueing propagation', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        aiModel: 'sam2.1_hiera_tiny',
        masks: [{
          id: 'annotation-6',
          annotationId: '6',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
          metadata: {
            source: 'sam2.1_hiera_tiny_propagation',
            source_annotation_id: 5,
            source_mask_id: 'annotation-5',
            propagation_seed_signature: 'seed-signature-5',
          },
        }],
      });
    });

    expect(screen.queryByLabelText('传播权重')).not.toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    const propagationWeightSelect = screen.getByLabelText('传播权重');
    expect(propagationWeightSelect).toHaveClass('bg-[#050809]');
    expect(within(propagationWeightSelect).getByRole('option', { name: 'tiny' })).toHaveClass('text-cyan-100');
    fireEvent.change(propagationWeightSelect, { target: { value: 'sam2.1_hiera_small' } });
    expect(propagationWeightSelect).toHaveValue('sam2.1_hiera_small');
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
      model: 'sam2.1_hiera_small',
      steps: [expect.objectContaining({
        seed: expect.objectContaining({
          source_annotation_id: 5,
          source_mask_id: 'annotation-5',
          propagation_seed_signature: 'seed-signature-5',
        }),
      })],
    })));
    await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask，处理 3 帧次，删除旧区域 0 个，保存 2 个区域')).toBeInTheDocument());
  });

  it('shows propagation task progress and reports empty results', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
    ]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    });
    apiMock.queuePropagationTask.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0, message: '自动传播任务已入队' });
    apiMock.getTask.mockResolvedValueOnce({
      id: 44,
      status: 'success',
      progress: 100,
      message: '自动传播完成，但没有生成新的 mask',
      result: { processed_frame_count: 2, created_annotation_count: 0, completed_steps: 1 },
    });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'annotation-7',
          annotationId: '7',
          frameId: 10 as unknown as string,
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    const progressPanel = await screen.findByLabelText('自动传播进度');
    expect(progressPanel).toBeInTheDocument();
    expect(within(progressPanel).getByText('0%')).toBeInTheDocument();

    expect(await screen.findByText(/没有生成新的 mask/)).toBeInTheDocument();
  });

  it('lets users select the propagation range on the timeline before queueing', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
      { id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
      { id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
    ]);
    apiMock.buildAnnotationPayload.mockReturnValueOnce({
      project_id: 1,
      frame_id: 10,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    });

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'annotation-8',
          annotationId: '8',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    const processingBar = screen.getByLabelText('视频处理进度条');
    vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
      left: 0,
      right: 100,
      top: 0,
      bottom: 10,
      width: 100,
      height: 10,
      x: 0,
      y: 0,
      toJSON: () => ({}),
    });
    fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
    fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
    fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });

    expect(screen.getByLabelText('传播起始帧')).toHaveValue(2);
    expect(screen.getByLabelText('传播结束帧')).toHaveValue(5);

    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
      frame_id: 10,
      steps: [expect.objectContaining({ direction: 'forward', max_frames: 5 })],
    })));
  });

  it('keeps propagation history visible because top range clearing is removed', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
      { id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
      { id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
    ]);
    apiMock.buildAnnotationPayload.mockReturnValue({
      project_id: 1,
      frame_id: 10,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        label: '胆囊',
        color: '#ff0000',
      },
      bbox: [0.1, 0.1, 0.2, 0.2],
    });
    apiMock.getProjectAnnotations
      .mockResolvedValueOnce([])
      .mockResolvedValue([
        { id: 101, frame_id: 11 },
        { id: 102, frame_id: 12 },
      ]);
    apiMock.annotationToMask.mockImplementation((annotation) => ({
      id: `annotation-${annotation.id}`,
      annotationId: String(annotation.id),
      frameId: String(annotation.frame_id),
      pathData: 'M 0 0 Z',
      label: 'Propagated',
      color: '#ff0000',
      saved: true,
      saveStatus: 'saved',
      segmentation: [[64, 36, 192, 36, 192, 108]],
      metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 },
    }));
    apiMock.deleteAnnotation.mockResolvedValue(undefined);

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
    act(() => {
      useStore.setState({
        masks: [{
          id: 'annotation-8',
          annotationId: '8',
          frameId: '10',
          pathData: 'M 0 0 Z',
          label: '胆囊',
          color: '#ff0000',
          segmentation: [[64, 36, 192, 36, 192, 108]],
          bbox: [64, 36, 128, 72],
        }],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    const processingBar = screen.getByLabelText('视频处理进度条');
    vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
      left: 0,
      right: 100,
      top: 0,
      bottom: 10,
      width: 100,
      height: 10,
      x: 0,
      y: 0,
      toJSON: () => ({}),
    });
    fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
    fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
    fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    expect(await screen.findByTestId('propagation-history-segment')).toBeInTheDocument();

    act(() => {
      useStore.setState({
        masks: [
          { id: 'annotation-101', annotationId: '101', frameId: '11', pathData: 'M 1 1 Z', label: 'Propagated 1', color: '#ff0000', saved: true, saveStatus: 'saved', metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 } },
          { id: 'annotation-102', annotationId: '102', frameId: '12', pathData: 'M 2 2 Z', label: 'Propagated 2', color: '#ff0000', saved: true, saveStatus: 'saved', metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 } },
        ],
      });
    });

    expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument();
    expect(screen.getByTestId('propagation-history-segment')).toBeInTheDocument();
    expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
  });

  it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
    apiMock.getProjectFrames.mockResolvedValueOnce([
      { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
      { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
      { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
    ]);
    apiMock.getTask.mockResolvedValue({
      id: 31,
      status: 'success',
      progress: 100,
      message: '自动传播完成',
      result: {
        processed_frame_count: 8,
        created_annotation_count: 4,
        completed_steps: 4,
      },
    });
    apiMock.buildAnnotationPayload.mockImplementation((_projectId, mask) => (
      mask.id === 'annotation-10'
        ? {
          project_id: 1,
          frame_id: 11,
          mask_data: {
            polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]],
            label: '肝脏',
            color: '#00ff00',
          },
          bbox: [0.4, 0.4, 0.1, 0.1],
        }
        : {
        project_id: 1,
        frame_id: 11,
        mask_data: {
          polygons: [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
          label: '胆囊',
          color: '#ff0000',
        },
        bbox: [0.1, 0.1, 0.1, 0.1],
      }
    ));

    render(<VideoWorkspace />);
    await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
    act(() => {
      useStore.setState({
        currentFrameIndex: 1,
        masks: [
          {
            id: 'annotation-9',
            annotationId: '9',
            frameId: '11',
            pathData: 'M 0 0 Z',
            label: '胆囊',
            color: '#ff0000',
            segmentation: [[64, 36, 128, 36, 128, 72]],
          },
          {
            id: 'annotation-10',
            annotationId: '10',
            frameId: '11',
            pathData: 'M 1 1 Z',
            label: '肝脏',
            color: '#00ff00',
            segmentation: [[256, 144, 320, 144, 320, 180]],
          },
        ],
      });
    });

    fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
    fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
    fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
    fireEvent.click(screen.getByRole('button', { name: '开始传播' }));

    await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
    const queuedPayload = apiMock.queuePropagationTask.mock.calls[0][0];
    expect(queuedPayload.steps).toEqual([
      expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
      expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
      expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
      expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
    ]);
    await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask，处理 8 帧次，删除旧区域 0 个，保存 4 个区域')).toBeInTheDocument());
  });
});
