import { beforeEach, describe, expect, it, vi } from 'vitest';

const axiosMock = vi.hoisted(() => {
  const client = {
    get: vi.fn(),
    post: vi.fn(),
    patch: vi.fn(),
    delete: vi.fn(),
    interceptors: {
      request: { use: vi.fn() },
      response: { use: vi.fn() },
    },
  };
  return { client, create: vi.fn(() => client) };
});

vi.mock('axios', () => ({
  default: {
    create: axiosMock.create,
  },
}));

describe('api client contracts', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.setSystemTime(new Date('2026-05-01T00:00:00Z'));
  });

  it('maps backend project fields into frontend project fields', async () => {
    const { getProjects } = await import('./api');
    axiosMock.client.get.mockResolvedValueOnce({
      data: [
        {
          id: 7,
          name: 'Demo',
          description: 'desc',
          status: 'ready',
          frame_count: 12,
          original_fps: 29.97,
          parse_fps: 10,
          thumbnail_url: 'thumb',
          video_path: 'uploads/demo.mp4',
          source_type: 'video',
          created_at: 'created',
          updated_at: 'updated',
        },
      ],
    });

    await expect(getProjects()).resolves.toEqual([
      expect.objectContaining({
        id: '7',
        name: 'Demo',
        status: 'ready',
        frames: 12,
        fps: '10FPS',
        thumbnail_url: 'thumb',
        video_path: 'uploads/demo.mp4',
        source_type: 'video',
        original_fps: 29.97,
        parse_fps: 10,
        createdAt: 'created',
        updatedAt: 'updated',
      }),
    ]);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/projects');
  });

  it('updates projects with PATCH instead of the old PUT contract', async () => {
    const { updateProject } = await import('./api');
    axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 3, name: 'Renamed', status: 'ready' } });

    await updateProject('3', { name: 'Renamed' } as any);

    expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
  });

  it('copies projects through the copy endpoint', async () => {
    const { copyProject } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({ data: { id: 4, name: 'Copied', status: 'ready', frame_count: 3 } });

    await expect(copyProject('3', { mode: 'full' })).resolves.toEqual(expect.objectContaining({
      id: '4',
      name: 'Copied',
      frames: 3,
    }));

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/projects/3/copy', { mode: 'full' });
  });

  it('deletes projects through DELETE', async () => {
    const { deleteProject } = await import('./api');
    axiosMock.client.delete.mockResolvedValueOnce({ data: null });

    await expect(deleteProject('3')).resolves.toBeUndefined();

    expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3');
  });

  it('reports upload progress for video media imports', async () => {
    const { uploadMedia } = await import('./api');
    const onProgress = vi.fn();
    const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
    axiosMock.client.post.mockResolvedValueOnce({ data: { file_url: 'http://file', object_name: 'uploads/clip.mp4' } });

    await expect(uploadMedia(file, '9', { onProgress })).resolves.toEqual({
      url: 'http://file',
      id: 'uploads/clip.mp4',
    });
    const [, , config] = axiosMock.client.post.mock.calls.at(-1);
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload', expect.any(FormData), expect.objectContaining({
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: expect.any(Function),
    }));

    config.onUploadProgress({ loaded: 25, total: 100 });
    expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 });
  });

  it('reports upload progress for DICOM batch imports', async () => {
    const { uploadDicomBatch } = await import('./api');
    const onProgress = vi.fn();
    const file = new File(['dcm'], '1.dcm', { type: 'application/dicom' });
    axiosMock.client.post.mockResolvedValueOnce({ data: { project_id: 10, uploaded_count: 1, message: 'ok' } });

    await expect(uploadDicomBatch([file], undefined, { onProgress })).resolves.toEqual({
      project_id: 10,
      uploaded_count: 1,
      message: 'ok',
    });
    const [, , config] = axiosMock.client.post.mock.calls.at(-1);
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload/dicom', expect.any(FormData), expect.objectContaining({
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: expect.any(Function),
    }));

    config.onUploadProgress({ loaded: 10, total: 20 });
    expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: 20, percent: 50 });
  });

  it('normalizes missing template class maskids without using priority as the public id', async () => {
    const { getTemplates } = await import('./api');
    axiosMock.client.get.mockResolvedValueOnce({
      data: [{
        id: 2,
        name: 'Template',
        mapping_rules: {
          classes: [
            { id: 'c1', name: 'A', color: '#ff0000', zIndex: 100 },
            { id: 'c2', name: 'B', color: '#00ff00', zIndex: 10, maskId: 7 },
            { id: 'c3', name: 'C', color: '#0000ff', zIndex: 50 },
          ],
          rules: [],
        },
      }],
    });

    await expect(getTemplates()).resolves.toEqual([
      expect.objectContaining({
        classes: [
          expect.objectContaining({ id: 'c1', maskId: 1, zIndex: 100 }),
          expect.objectContaining({ id: 'c2', maskId: 7, zIndex: 10 }),
          expect.objectContaining({ id: 'c3', maskId: 2, zIndex: 50 }),
          expect.objectContaining({ id: 'reserved-unclassified', name: '待分类', maskId: 0, color: '#000000', zIndex: 0 }),
        ],
      }),
    ]);
  });

  it('calls admin user management and audit endpoints', async () => {
    const {
      getAdminUsers,
      createAdminUser,
      updateAdminUser,
      deleteAdminUser,
      getAuditLogs,
      resetDemoFactory,
    } = await import('./api');
    axiosMock.client.get
      .mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
      .mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
    axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
    axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 0 } });
    axiosMock.client.delete.mockResolvedValueOnce({ data: null });

    await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
    await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
    await updateAdminUser(2, { is_active: false });
    await deleteAdminUser(2);
    await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);

    expect(axiosMock.client.get).toHaveBeenNthCalledWith(1, '/api/admin/users');
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/admin/users', {
      username: 'doctor',
      password: 'secret123',
      role: 'annotator',
      is_active: true,
    });
    expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { is_active: false });
    expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
    expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });

    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
        project: { id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
        projects: [
          { id: 7, name: '演视LC视频序列', status: 'pending', source_type: 'video', frame_count: 0, video_path: 'uploads/7/Data_MyVideo_1.mp4' },
          { id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
        ],
        deleted_counts: { users: 1 },
        message: '演示环境已恢复出厂设置',
      },
    });
    await expect(resetDemoFactory('RESET_DEMO_FACTORY')).resolves.toEqual(expect.objectContaining({
      admin_user: expect.objectContaining({ username: 'admin' }),
      project: expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
      projects: [
        expect.objectContaining({ id: '7', name: '演视LC视频序列', frames: 0, source_type: 'video' }),
        expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
      ],
    }));
    expect(axiosMock.client.post).toHaveBeenLastCalledWith('/api/admin/demo-factory-reset', {
      confirmation: 'RESET_DEMO_FACTORY',
    });
  });

  it('normalizes legacy project status values returned by existing databases', async () => {
    const { getProjects } = await import('./api');
    axiosMock.client.get.mockResolvedValueOnce({
      data: [
        { id: 1, name: 'Old Ready', status: 'Ready' },
        { id: 2, name: 'Old Parsing', status: 'Parsing' },
        { id: 3, name: 'Old Error', status: 'Error' },
      ],
    });

    await expect(getProjects()).resolves.toEqual([
      expect.objectContaining({ status: 'ready' }),
      expect.objectContaining({ status: 'parsing' }),
      expect.objectContaining({ status: 'error' }),
    ]);
  });

  it('exports COCO from the backend route shape', async () => {
    const { exportCoco } = await import('./api');
    const blob = new Blob(['{}'], { type: 'application/json' });
    axiosMock.client.get.mockResolvedValueOnce({ data: blob });

    await expect(exportCoco('9')).resolves.toBe(blob);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/export/9/coco', {
      responseType: 'blob',
    });
  });

  it('exports PNG masks from the backend route shape', async () => {
    const { exportMasks } = await import('./api');
    const blob = new Blob(['zip'], { type: 'application/zip' });
    axiosMock.client.get.mockResolvedValueOnce({ data: blob });

    await expect(exportMasks('9')).resolves.toBe(blob);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/export/9/masks', {
      responseType: 'blob',
    });
  });

  it('exports combined segmentation results with scope, outputs, and mix opacity params', async () => {
    const { exportSegmentationResults } = await import('./api');
    const blob = new Blob(['zip'], { type: 'application/zip' });
    axiosMock.client.get.mockResolvedValueOnce({ data: blob });

    await expect(exportSegmentationResults('9', {
      scope: 'range',
      outputs: ['gt_label', 'pro_label', 'mix_label'],
      mixOpacity: 0.45,
      startFrame: 2,
      endFrame: 5,
      frameId: '12',
    })).resolves.toBe(blob);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/export/9/results', {
      params: {
        scope: 'range',
        mask_type: undefined,
        outputs: 'gt_label,pro_label,mix_label',
        mix_opacity: 0.45,
        start_frame: 2,
        end_frame: 5,
        frame_id: 12,
      },
      responseType: 'blob',
    });
  });

  it('loads dashboard overview from the backend summary endpoint', async () => {
    const { getDashboardOverview } = await import('./api');
    const overview = {
      summary: {
        project_count: 2,
        parsing_task_count: 1,
        annotation_count: 5,
        frame_count: 100,
        template_count: 3,
        system_load_percent: 12,
      },
      tasks: [
        { id: 'project-1', project_id: 1, name: 'Demo', progress: 60, status: 'pending', frame_count: 10, updated_at: 'now' },
      ],
      activity: [
        { id: 'project-1', kind: 'project', time: 'now', message: '项目状态: pending', project: 'Demo' },
      ],
    };
    axiosMock.client.get.mockResolvedValueOnce({ data: overview });

    await expect(getDashboardOverview()).resolves.toEqual(overview);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/dashboard/overview');
  });

  it('queues media parsing and manages processing task lifecycle', async () => {
    const { cancelTask, getTask, parseMedia, retryTask } = await import('./api');
    const task = {
      id: 12,
      task_type: 'parse_video',
      status: 'queued',
      progress: 0,
      message: '解析任务已入队',
      project_id: 9,
      celery_task_id: 'celery-12',
      payload: { source_type: 'video' },
      result: null,
      error: null,
      created_at: 'created',
      started_at: null,
      finished_at: null,
      updated_at: 'updated',
    };
    axiosMock.client.post.mockResolvedValueOnce({ data: task });
    axiosMock.client.get.mockResolvedValueOnce({ data: { ...task, status: 'success', progress: 100 } });
    axiosMock.client.post.mockResolvedValueOnce({ data: { ...task, status: 'cancelled', progress: 100 } });
    axiosMock.client.post.mockResolvedValueOnce({ data: { ...task, id: 13, status: 'queued', progress: 0 } });

    await expect(parseMedia('9', { parseFps: 15, maxFrames: 120, targetWidth: 960 })).resolves.toEqual(task);
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/parse', null, {
      params: { project_id: '9', parse_fps: 15, max_frames: 120, target_width: 960 },
    });

    await expect(getTask(12)).resolves.toEqual(expect.objectContaining({ status: 'success', progress: 100 }));
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/tasks/12');

    await expect(cancelTask(12)).resolves.toEqual(expect.objectContaining({ status: 'cancelled' }));
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/tasks/12/cancel');

    await expect(retryTask(12)).resolves.toEqual(expect.objectContaining({ id: 13, status: 'queued' }));
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/tasks/12/retry');
  });

  it('lists, saves, updates, and deletes annotations with the backend annotation contract', async () => {
    const { deleteAnnotation, getProjectAnnotations, propagateMasks, queuePropagationTask, saveAnnotation, updateAnnotation } = await import('./api');
    const saved = {
      id: 1,
      project_id: 9,
      frame_id: 5,
      template_id: 2,
      mask_data: { polygons: [[[0, 0], [1, 0], [1, 1]]] },
      points: null,
      bbox: null,
      created_at: 'created',
      updated_at: 'updated',
    };
    axiosMock.client.get.mockResolvedValueOnce({ data: [saved] });
    axiosMock.client.post.mockResolvedValueOnce({ data: saved });

    await expect(getProjectAnnotations('9', '5')).resolves.toEqual([saved]);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/ai/annotations', {
      params: { project_id: 9, frame_id: 5 },
    });

    await expect(saveAnnotation({
      project_id: 9,
      frame_id: 5,
      template_id: 2,
      mask_data: { polygons: [[[0, 0], [1, 0], [1, 1]]], label: 'mask' },
    })).resolves.toEqual(saved);
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/annotate', {
      project_id: 9,
      frame_id: 5,
      template_id: 2,
      mask_data: { polygons: [[[0, 0], [1, 0], [1, 1]]], label: 'mask' },
    });

    axiosMock.client.patch.mockResolvedValueOnce({ data: { ...saved, mask_data: { ...saved.mask_data, label: 'updated' } } });
    await expect(updateAnnotation('1', {
      template_id: 2,
      mask_data: { polygons: [[[0, 0], [1, 0], [1, 1]]], label: 'updated' },
    })).resolves.toEqual(expect.objectContaining({ mask_data: expect.objectContaining({ label: 'updated' }) }));
    expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/ai/annotations/1', {
      template_id: 2,
      mask_data: { polygons: [[[0, 0], [1, 0], [1, 1]]], label: 'updated' },
    });

    axiosMock.client.delete.mockResolvedValueOnce({ data: null });
    await expect(deleteAnnotation('1')).resolves.toBeUndefined();
    expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1');

    axiosMock.client.delete.mockRejectedValueOnce({ response: { status: 404 } });
    await expect(deleteAnnotation('missing')).resolves.toBeUndefined();
    expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/missing');

    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        model: 'sam2.1_hiera_tiny',
        direction: 'forward',
        source_frame_id: 5,
        processed_frame_count: 3,
        created_annotation_count: 2,
        annotations: [saved],
      },
    });
    await expect(propagateMasks({
      project_id: 9,
      frame_id: 5,
      model: 'sam2.1_hiera_tiny',
      seed: {
        polygons: [[[0, 0], [1, 0], [1, 1]]],
        label: 'mask',
        color: '#06b6d4',
      },
      direction: 'forward',
      max_frames: 30,
    })).resolves.toEqual(expect.objectContaining({ created_annotation_count: 2 }));
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate', {
      project_id: 9,
      frame_id: 5,
      model: 'sam2.1_hiera_tiny',
      seed: {
        polygons: [[[0, 0], [1, 0], [1, 1]]],
        label: 'mask',
        color: '#06b6d4',
      },
      direction: 'forward',
      max_frames: 30,
    }, {
      timeout: 600000,
    });

    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        id: 33,
        task_type: 'propagate_masks',
        status: 'queued',
        progress: 0,
        message: '自动传播任务已入队',
      },
    });
    await expect(queuePropagationTask({
      project_id: 9,
      frame_id: 5,
      model: 'sam2.1_hiera_tiny',
      steps: [{
        seed: {
          polygons: [[[0, 0], [1, 0], [1, 1]]],
          label: 'mask',
          color: '#06b6d4',
        },
        direction: 'forward',
        max_frames: 30,
      }],
      include_source: false,
      save_annotations: true,
    })).resolves.toEqual(expect.objectContaining({ id: 33, task_type: 'propagate_masks' }));
    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate/task', {
      project_id: 9,
      frame_id: 5,
      model: 'sam2.1_hiera_tiny',
      steps: [{
        seed: {
          polygons: [[[0, 0], [1, 0], [1, 1]]],
          label: 'mask',
          color: '#06b6d4',
        },
        direction: 'forward',
        max_frames: 30,
      }],
      include_source: false,
      save_annotations: true,
    });
  });

  it('imports GT masks through multipart form data', async () => {
    const { importGtMask } = await import('./api');
    const file = new File(['mask'], 'mask.png', { type: 'image/png' });
    const saved = [{ id: 1, project_id: 9, frame_id: 5, template_id: null, mask_data: null, points: null, bbox: null }];
    axiosMock.client.post.mockResolvedValueOnce({ data: saved });

    await expect(importGtMask(file, '9', '5', '2', { unknownColorPolicy: 'discard' })).resolves.toEqual(saved);
    expect(axiosMock.client.post).toHaveBeenCalledWith(
      '/api/ai/import-gt-mask',
      expect.any(FormData),
      { headers: { 'Content-Type': 'multipart/form-data' } },
    );
    const form = axiosMock.client.post.mock.calls.at(-1)?.[1] as FormData;
    expect(form.get('file')).toBe(file);
    expect(form.get('project_id')).toBe('9');
    expect(form.get('frame_id')).toBe('5');
    expect(form.get('template_id')).toBe('2');
    expect(form.get('unknown_color_policy')).toBe('discard');
  });

  it('builds annotation payloads from frontend masks and restores saved annotations to masks', async () => {
    const { annotationToMask, buildAnnotationPayload } = await import('./api');
    const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };
    const payload = buildAnnotationPayload('9', {
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: '胆囊',
      color: '#ff0000',
      classId: 'c1',
      className: '胆囊',
      classZIndex: 20,
      classMaskId: 7,
      segmentation: [[10, 10, 90, 10, 90, 40]],
      bbox: [10, 10, 80, 30],
      metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
    }, frame, '2');

    expect(payload).toEqual({
      project_id: 9,
      frame_id: 5,
      template_id: 2,
      mask_data: {
        instance_id: 'm1',
        polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
        label: '胆囊',
        color: '#ff0000',
        class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
        geometry_smoothing: { strength: 35, method: 'chaikin' },
      },
      bbox: [0.1, 0.2, 0.8, 0.6],
      points: [[0.6333333333333333, 0.4]],
    });

    expect(annotationToMask({
      id: 3,
      project_id: 9,
      frame_id: 5,
      template_id: 2,
      mask_data: {
        polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
        label: '旧标签',
        color: '#06b6d4',
        class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
        source: 'sam2.1_hiera_tiny_propagation',
        propagated_from_frame_id: 4,
        instance_id: 'instance:gallbladder-1',
        source_instance_id: 'gallbladder-1',
        geometry_smoothing: { strength: 35, method: 'chaikin' },
      },
      points: [[0.5, 0.5]],
      bbox: null,
      created_at: 'created',
      updated_at: 'updated',
    }, frame)).toEqual(expect.objectContaining({
      id: 'annotation-3',
      annotationId: '3',
      frameId: '5',
      templateId: '2',
      classId: 'c1',
      className: '胆囊',
      classZIndex: 20,
      classMaskId: 7,
      label: '胆囊',
      color: '#ff0000',
      saveStatus: 'saved',
      saved: true,
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      points: [[50, 25]],
      bbox: [10, 10, 80, 30],
      metadata: {
        source: 'sam2.1_hiera_tiny_propagation',
        propagated_from_frame_id: 4,
        instance_id: 'instance:gallbladder-1',
        source_instance_id: 'gallbladder-1',
        geometry_smoothing: { strength: 35, method: 'chaikin' },
      },
    }));
  });

  it('sends selected mask geometry to backend smoothing and maps smoothed polygons back to pixels', async () => {
    const { smoothMaskGeometry } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        polygons: [[[0.12, 0.24], [0.88, 0.24], [0.88, 0.76], [0.12, 0.76]]],
        topology_anchor_count: 4,
        topology_anchors: [[0.12, 0.24]],
        area: 0.4,
        bbox: [0.12, 0.24, 0.76, 0.52],
        smoothing: { strength: 35, method: 'chaikin' },
        message: '已应用边缘平滑强度 35',
      },
    });

    const result = await smoothMaskGeometry({
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: '胆囊',
      color: '#ff0000',
      segmentation: [[10, 10, 90, 10, 90, 40]],
      bbox: [10, 10, 80, 30],
    }, { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 }, 35);

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/smooth-mask', {
      frame_id: 5,
      mask_data: {
        polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
        label: '胆囊',
        color: '#ff0000',
      },
      points: undefined,
      bbox: [0.1, 0.2, 0.8, 0.6],
      strength: 35,
      method: 'chaikin',
    });
    expect(result).toEqual(expect.objectContaining({
      pathData: 'M 12 12 L 88 12 L 88 38 L 12 38 Z',
      segmentation: [[12, 12, 88, 12, 88, 38, 12, 38]],
      bbox: [12, 12, 76, 26],
      area: 1976,
      smoothing: { strength: 35, method: 'chaikin' },
    }));
  });

  it('preserves editable point regions in annotation payloads', async () => {
    const { buildAnnotationPayload } = await import('./api');
    const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };

    expect(buildAnnotationPayload('9', {
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: 'GT Mask',
      color: '#22c55e',
      segmentation: [[10, 10, 90, 10, 90, 40]],
      points: [[50, 25]],
    }, frame)).toEqual(expect.objectContaining({
      points: [[0.5, 0.5]],
    }));
  });

  it('preserves hollow mask holes when saving and hydrating annotations', async () => {
    const { annotationToMask, buildAnnotationPayload } = await import('./api');
    const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
    const hollowMask = {
      id: 'm-hole',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
      label: '中空区域',
      color: '#22c55e',
      segmentation: [
        [10, 10, 90, 10, 90, 90, 10, 90],
        [35, 35, 65, 35, 65, 65, 35, 65],
      ],
      metadata: { hasHoles: true, polygonRingCounts: [2] },
    };

    const payload = buildAnnotationPayload('9', hollowMask, frame);

    expect(payload?.mask_data).toEqual(expect.objectContaining({
      polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
      holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
      hasHoles: true,
      polygonRingCounts: [2],
    }));

    const hydrated = annotationToMask({
      id: 33,
      project_id: 9,
      frame_id: 5,
      template_id: null,
      mask_data: {
        polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
        holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
        label: '中空区域',
        color: '#22c55e',
      },
      points: null,
      bbox: null,
      created_at: 'created',
      updated_at: 'updated',
    }, frame);

    expect(hydrated).toEqual(expect.objectContaining({
      segmentation: [
        [10, 10, 90, 10, 90, 90, 10, 90],
        [35, 35, 65, 35, 65, 65, 35, 65],
      ],
      metadata: expect.objectContaining({ hasHoles: true, polygonRingCounts: [2] }),
    }));
  });

  it('restores disconnected polygons from one saved annotation as one mask with a combined bbox', async () => {
    const { annotationToMask } = await import('./api');
    const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };

    const hydrated = annotationToMask({
      id: 44,
      project_id: 9,
      frame_id: 5,
      template_id: null,
      mask_data: {
        polygons: [
          [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]],
          [[0.7, 0.7], [0.9, 0.7], [0.9, 0.9], [0.7, 0.9]],
        ],
        label: '多区域',
        color: '#22c55e',
        source: 'sam2.1_hiera_tiny_propagation',
      },
      points: null,
      bbox: null,
      created_at: 'created',
      updated_at: 'updated',
    }, frame);

    expect(hydrated).toEqual(expect.objectContaining({
      id: 'annotation-44',
      pathData: 'M 10 10 L 20 10 L 20 20 L 10 20 Z M 70 70 L 90 70 L 90 90 L 70 90 Z',
      segmentation: [
        [10, 10, 20, 10, 20, 20, 10, 20],
        [70, 70, 90, 70, 90, 90, 70, 90],
      ],
      bbox: [10, 10, 80, 80],
      area: 500,
    }));
  });

  it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
    const { buildAnnotationPayload } = await import('./api');
    const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };

    expect(buildAnnotationPayload('9', {
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: 'Tracked',
      color: '#22c55e',
      segmentation: [[10, 10, 90, 10, 90, 40]],
      metadata: {
        source: 'sam2_propagation',
        propagated_from_frame_id: 1,
        source_annotation_id: 7,
        source_mask_id: 'annotation-7',
        source_instance_id: 'tracked-instance-7',
        instance_id: 'tracked-instance-7',
        propagation_seed_key: 'annotation:7',
        geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
      },
    }, frame)).toEqual(expect.objectContaining({
      mask_data: expect.objectContaining({
        source: 'sam2_propagation',
        propagated_from_frame_id: 1,
        source_annotation_id: 7,
        source_mask_id: 'annotation-7',
        source_instance_id: 'tracked-instance-7',
        instance_id: 'tracked-instance-7',
        propagation_seed_key: 'annotation:7',
      }),
    }));
    expect(buildAnnotationPayload('9', {
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: 'Tracked',
      color: '#22c55e',
      segmentation: [[10, 10, 90, 10, 90, 40]],
      metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
    }, frame)?.mask_data).not.toHaveProperty('geometry_smoothing_preview');
  });

  it('normalizes positive and negative point prompts for AI prediction', async () => {
    const { predictMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        polygons: [[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]]],
        scores: [0.9],
      },
    });

    const result = await predictMask({
      imageId: '42',
      imageWidth: 400,
      imageHeight: 200,
      points: [
        { x: 200, y: 100, type: 'pos' },
        { x: 40, y: 20, type: 'neg' },
      ],
    });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/predict', {
      image_id: 42,
      prompt_type: 'point',
      prompt_data: {
        points: [[0.5, 0.5], [0.1, 0.1]],
        labels: [1, 0],
      },
      model: 'sam2.1_hiera_tiny',
    });
    expect(result.masks[0]).toEqual(expect.objectContaining({
      pathData: 'M 100 50 L 300 50 L 300 150 L 100 150 Z',
      segmentation: [[100, 50, 300, 50, 300, 150, 100, 150]],
      bbox: [100, 50, 200, 100],
      area: 20000,
      confidence: 0.9,
    }));
  });

  it('normalizes box prompts for AI prediction', async () => {
    const { predictMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

    await predictMask({
      imageId: '5',
      imageWidth: 640,
      imageHeight: 320,
      box: { x1: 64, y1: 32, x2: 320, y2: 160 },
    });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/predict', {
      image_id: 5,
      prompt_type: 'box',
      prompt_data: [0.1, 0.1, 0.5, 0.5],
      model: 'sam2.1_hiera_tiny',
    });
  });

  it('sends normalized mask geometry to the backend analyzer', async () => {
    const { analyzeMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({
      data: {
        confidence: 0.87,
        confidence_source: 'model_score',
        topology_anchor_count: 3,
        topology_anchors: [],
        area: 0.12,
        bbox: [0.1, 0.2, 0.8, 0.6],
        source: 'sam2.1_hiera_tiny',
        message: 'ok',
      },
    });

    const result = await analyzeMask({
      id: 'm1',
      frameId: '5',
      pathData: 'M 10 10 L 90 10 L 90 40 Z',
      label: '胆囊',
      color: '#ff0000',
      segmentation: [[10, 10, 90, 10, 90, 40]],
      bbox: [10, 10, 80, 30],
      metadata: { source: 'sam2.1_hiera_tiny', score: 0.87 },
    }, { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 }, { extractSkeleton: true });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/analyze-mask', {
      frame_id: 5,
      mask_data: {
        polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
        label: '胆囊',
        color: '#ff0000',
        source: 'sam2.1_hiera_tiny',
        score: 0.87,
      },
      points: undefined,
      bbox: [0.1, 0.2, 0.8, 0.6],
      extract_skeleton: true,
    });
    expect(result.confidence).toBe(0.87);
  });

  it('normalizes combined box and point prompts for interactive SAM2 refinement', async () => {
    const { predictMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

    await predictMask({
      imageId: '5',
      imageWidth: 640,
      imageHeight: 320,
      box: { x1: 64, y1: 32, x2: 320, y2: 160 },
      points: [
        { x: 128, y: 64, type: 'pos' },
        { x: 256, y: 128, type: 'neg' },
      ],
    });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/predict', {
      image_id: 5,
      prompt_type: 'interactive',
      prompt_data: {
        box: [0.1, 0.1, 0.5, 0.5],
        points: [[0.2, 0.2], [0.4, 0.4]],
        labels: [1, 0],
      },
      model: 'sam2.1_hiera_tiny',
    });
  });

  it('serializes text-only prediction as semantic when called directly', async () => {
    const { predictMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

    await predictMask({
      imageId: '6',
      imageWidth: 640,
      imageHeight: 360,
      text: '分割胆囊',
    });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/predict', {
      image_id: 6,
      prompt_type: 'semantic',
      prompt_data: '分割胆囊',
      model: 'sam2.1_hiera_tiny',
    });
  });

  it('passes AI post-processing options to prediction endpoint', async () => {
    const { predictMask } = await import('./api');
    axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

    await predictMask({
      imageId: '7',
      imageWidth: 640,
      imageHeight: 360,
      points: [{ x: 320, y: 180, type: 'pos' }],
      options: {
        crop_to_prompt: true,
        auto_filter_background: true,
        min_score: 0.05,
      },
    });

    expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/predict', {
      image_id: 7,
      prompt_type: 'point',
      prompt_data: {
        points: [[0.5, 0.5]],
        labels: [1],
      },
      model: 'sam2.1_hiera_tiny',
      options: {
        crop_to_prompt: true,
        auto_filter_background: true,
        min_score: 0.05,
      },
    });
  });

  it('loads AI model and GPU runtime status', async () => {
    const { getAiModelStatus } = await import('./api');
    const status = {
      selected_model: 'sam2.1_hiera_tiny',
      gpu: { available: false, device: 'cpu', name: null, torch_available: true, torch_version: '2.x', cuda_version: null },
      models: [
        { id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: ['point'], message: 'ready', package_available: true, checkpoint_exists: true, checkpoint_path: 'model.pt', python_ok: true, torch_ok: true, cuda_required: false },
      ],
    };
    axiosMock.client.get.mockResolvedValueOnce({ data: status });

    await expect(getAiModelStatus('sam2.1_hiera_tiny')).resolves.toEqual(status);
    expect(axiosMock.client.get).toHaveBeenCalledWith('/api/ai/models/status', {
      params: { selected_model: 'sam2.1_hiera_tiny' },
    });
  });
});
