import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InferenceOptions } from '@/components/inference/inference-options';
import {
  useInferenceOptionsStore,
  MAX_LABEL_FILE_BYTES,
  type InferenceOptionsResponse,
  type InferenceOptionsError,
} from '@/stores/inference-options-store';
import { api, type ApiResponse } from '@/lib/api';

/**
 * The store calls `api.post`/`api.postForm` with the widened error type so a
 * parse error keeps its `line` number. `vi.mocked` resolves the generic to its
 * default `ApiError`, so responses are built through this helper to stay in the
 * shape the store actually receives.
 */
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;

vi.mock('@/lib/api', () => ({
  api: {
    get: vi.fn().mockResolvedValue({ success: true, data: {} }),
    post: vi.fn().mockResolvedValue({ success: true, data: {} }),
    postForm: vi.fn().mockResolvedValue({ success: true, data: {} }),
  },
  getRelayHeaders: vi.fn().mockReturnValue({}),
}));

const DEVICE_ID = 'dev-1';
const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`;

beforeEach(() => {
  vi.clearAllMocks();
  vi.mocked(api.post).mockResolvedValue({ success: true, data: {} });
  vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} });
  useInferenceOptionsStore.getState().reset();
});

/**
 * Radix Select cannot be opened by a plain click under jsdom (pointer capture),
 * but the keyboard path works: Enter opens the listbox, a click commits.
 */
async function selectOption(trigger: HTMLElement, optionName: string) {
  fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
  fireEvent.click(await screen.findByRole('option', { name: optionName }));
}

function taskTypeTrigger() {
  return screen.getByTestId('inference-task-type-trigger');
}

function labelFile(name = 'labels.txt', content = '0 剪刀\n1 石頭\n2 布\n') {
  return new File([content], name, { type: 'text/plain' });
}

function uploadLabelFile(file: File) {
  const input = screen.getByTestId('inference-label-file-input') as HTMLInputElement;
  fireEvent.change(input, { target: { files: [file] } });
}

describe('InferenceOptions — parsing mode selector', () => {
  it('mirrors the task type reported by the latest result', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    expect(taskTypeTrigger()).toHaveTextContent('分類');
  });

  it('shows object detection for a detection result', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
  });

  it('falls back to object detection for the legacy "detection" spelling (R-4)', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="detection" />);
    expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
  });

  it('falls back to object detection when no result has arrived yet', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType={undefined} />);
    expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
  });

  it('POSTs the newly chosen task type', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    await selectOption(taskTypeTrigger(), '分類');

    await waitFor(() => expect(api.post).toHaveBeenCalled());
    expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
  });

  it('keeps showing the user choice after it is applied, not the stale result type', async () => {
    // The prop still says object_detection (no new frame has arrived yet). The
    // explicit choice must win, otherwise the selector snaps back and looks
    // like the switch failed.
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    await selectOption(taskTypeTrigger(), '分類');

    await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('分類'));
  });

  it('adopts the task type echoed back by the server', async () => {
    vi.mocked(api.post).mockResolvedValue({
      success: true,
      data: { taskType: 'object_detection' },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    await selectOption(taskTypeTrigger(), '分類');

    // Server refused to switch and said "still object_detection" — the UI must
    // not claim classification is in effect.
    await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('物件偵測'));
  });

  it('surfaces a failed switch', async () => {
    vi.mocked(api.post).mockResolvedValue({
      success: false,
      error: { code: 'DEVICE_NOT_CONNECTED', message: 'device not connected' },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    await selectOption(taskTypeTrigger(), '分類');

    const err = await screen.findByTestId('inference-options-error');
    expect(err.textContent).toContain('device not connected');
  });
});

describe('InferenceOptions — label section visibility', () => {
  it('hides the label section for object detection', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    expect(screen.queryByTestId('inference-label-section')).toBeNull();
  });

  it('shows the label section for classification', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
  });

  it('reveals the label section once the user switches to classification', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
    expect(screen.queryByTestId('inference-label-section')).toBeNull();

    await selectOption(taskTypeTrigger(), '分類');
    await waitFor(() =>
      expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(),
    );
  });

  it('hides the label section again when switching back to detection', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();

    await selectOption(taskTypeTrigger(), '物件偵測');
    await waitFor(() => expect(screen.queryByTestId('inference-label-section')).toBeNull());
  });
});

describe('InferenceOptions — label upload', () => {
  it('uploads the picked file as multipart to the options endpoint', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());

    await waitFor(() => expect(api.postForm).toHaveBeenCalled());
    const [path, form] = vi.mocked(api.postForm).mock.calls[0];
    expect(path).toBe(OPTIONS_PATH);
    expect(form).toBeInstanceOf(FormData);
    expect((form.get('labelFile') as File).name).toBe('labels.txt');
  });

  it('shows the applied labels returned by the server', async () => {
    vi.mocked(api.postForm).mockResolvedValue({
      success: true,
      data: { labelCount: 3, labels: ['剪刀', '石頭', '布'] },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());

    await waitFor(() => {
      const status = screen.getByTestId('inference-label-status');
      expect(status.textContent).toContain('3');
      expect(status.textContent).toContain('剪刀');
    });
  });

  it('says no labels are set before anything is uploaded', () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    expect(screen.getByTestId('inference-label-status').textContent).toContain(
      '尚未上傳標籤檔',
    );
  });

  it('offers a clear action only after labels are applied', async () => {
    vi.mocked(api.postForm).mockResolvedValue({
      success: true,
      data: { labels: ['剪刀', '石頭', '布'] },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    expect(screen.queryByTestId('inference-label-clear-btn')).toBeNull();

    uploadLabelFile(labelFile());
    await waitFor(() =>
      expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
    );
  });

  it('clears the mapping through the endpoint and drops back to raw indices', async () => {
    vi.mocked(api.postForm).mockResolvedValue({
      success: true,
      data: { labels: ['剪刀', '石頭', '布'] },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());
    await waitFor(() =>
      expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
    );

    fireEvent.click(screen.getByTestId('inference-label-clear-btn'));

    await waitFor(() =>
      expect(screen.getByTestId('inference-label-status').textContent).toContain(
        '尚未上傳標籤檔',
      ),
    );
    // Clearing goes over JSON with an explicit empty array — an omitted field
    // would leave the mapping in place on the device.
    expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
  });

  it('lets the same file be re-picked after fixing it on disk', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());
    await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(1));

    // The input value is reset after each pick, so selecting the same filename
    // again still fires a change event.
    uploadLabelFile(labelFile());
    await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(2));
  });
});

describe('InferenceOptions — label upload errors', () => {
  it('rejects a non-.txt file without hitting the network', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile('labels.json', '{}'));

    expect(await screen.findByTestId('inference-options-error')).toHaveTextContent(
      '只接受 .txt 或 .names 檔',
    );
    expect(api.postForm).not.toHaveBeenCalled();
  });

  it('accepts a .names file', async () => {
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile('coco.names'));

    await waitFor(() => expect(api.postForm).toHaveBeenCalled());
    expect(screen.queryByTestId('inference-options-error')).toBeNull();
  });

  it('rejects an oversized file without hitting the network', async () => {
    const huge = labelFile('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(huge);

    expect(await screen.findByTestId('inference-options-error')).toHaveTextContent('過大');
    expect(api.postForm).not.toHaveBeenCalled();
  });

  it('shows the offending line number for a parse error', async () => {
    vi.mocked(api.postForm).mockResolvedValue(
      reply({
        success: false,
        error: {
          code: 'LABEL_PARSE_ERROR',
          message: "index 必須為非負整數，收到 'abc'",
          line: 5,
        },
      }),
    );
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());

    const err = await screen.findByTestId('inference-options-error');
    // The line number is the whole reason parsing lives server-side — losing it
    // would leave the user hunting through their file blind.
    expect(err.textContent).toContain('5');
    expect(err.textContent).toContain('非負整數');
  });

  it('shows a generic failure message when the server sends no line number', async () => {
    vi.mocked(api.postForm).mockResolvedValue({
      success: false,
      error: { code: 'STORAGE_ERROR', message: 'could not write labels' },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());

    const err = await screen.findByTestId('inference-options-error');
    expect(err.textContent).toContain('could not write labels');
  });

  it('keeps the previous mapping when a replacement upload fails', async () => {
    vi.mocked(api.postForm).mockResolvedValue({
      success: true,
      data: { labels: ['剪刀', '石頭', '布'] },
    });
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile());
    await waitFor(() =>
      expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀'),
    );

    vi.mocked(api.postForm).mockResolvedValue(
      reply({
        success: false,
        error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
      }),
    );
    uploadLabelFile(labelFile('bad.txt', 'garbage'));

    await screen.findByTestId('inference-options-error');
    // A failed replacement must not silently wipe the mapping that is still in
    // effect on the device.
    expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀');
  });

  it('clears a stale error once a later upload succeeds', async () => {
    vi.mocked(api.postForm).mockResolvedValue(
      reply({
        success: false,
        error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
      }),
    );
    render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
    uploadLabelFile(labelFile('bad.txt'));
    await screen.findByTestId('inference-options-error');

    vi.mocked(api.postForm).mockResolvedValue({
      success: true,
      data: { labels: ['剪刀'] },
    });
    uploadLabelFile(labelFile());

    await waitFor(() => expect(screen.queryByTestId('inference-options-error')).toBeNull());
  });
});
