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: '30FPS',
        thumbnail_url: 'thumb',
        video_path: 'uploads/demo.mp4',
        source_type: 'video',
        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('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('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('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, 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.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,
    });
  });

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

  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,
      segmentation: [[10, 10, 90, 10, 90, 40]],
      bbox: [10, 10, 80, 30],
    }, frame, '2');

    expect(payload).toEqual({
      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: '#ff0000',
        class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
      },
      bbox: [0.1, 0.2, 0.8, 0.6],
    });

    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 },
        source: 'sam2.1_hiera_tiny_propagation',
        propagated_from_frame_id: 4,
      },
      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,
      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,
      },
    }));
  });

  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('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' },
    });
  });
});
