import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebouncedTrace } from '../useDebouncedTrace';

// ---------- helpers ----------

function mockFetchOk(body: unknown) {
  return vi.fn().mockResolvedValue({
    ok: true,
    status: 200,
    json: () => Promise.resolve(body),
    text: () => Promise.resolve(JSON.stringify(body)),
  });
}

function mockFetchFail(status: number, detail: string) {
  return vi.fn().mockResolvedValue({
    ok: false,
    status,
    statusText: detail,
    text: () => Promise.resolve(detail),
  });
}

const TRACE_RESPONSE = {
  output: '<svg viewBox="0 0 100 100"><path d="M0,0"/></svg>',
  format: 'svg',
  metadata: {
    format: 'svg',
    path_count: 1,
    node_count_total: 2,
    open_paths: 0,
    island_count: 0,
    warnings: [],
    processing_ms: 42,
  },
};

// ---------- setup ----------
// Use real timers with very short debounce (10ms) to avoid fake-timer
// interaction issues with React's async state updates.

const originalFetch = globalThis.fetch;
const DEBOUNCE_MS = 10;

beforeEach(() => {
  globalThis.fetch = vi.fn().mockRejectedValue(new Error('fetch not mocked'));
});

afterEach(() => {
  globalThis.fetch = originalFetch;
});

// ---------- tests ----------

describe('useDebouncedTrace', () => {
  it('returns null output when no file is provided', () => {
    const { result } = renderHook(() =>
      useDebouncedTrace(null, 'sign', {}, DEBOUNCE_MS),
    );

    expect(result.current.svgOutput).toBeNull();
    expect(result.current.metadata).toBeNull();
    expect(result.current.isLoading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('debounces — only the last call fires after the delay', async () => {
    globalThis.fetch = mockFetchOk(TRACE_RESPONSE);
    const file = new File(['pixels'], 'test.png', { type: 'image/png' });

    const { result, rerender } = renderHook(
      ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS),
      { initialProps: { params: { epsilon: 1 } as Record<string, unknown> } },
    );

    // Rapid param changes — each restarts the debounce
    rerender({ params: { epsilon: 2 } });
    rerender({ params: { epsilon: 3 } });

    // Wait for debounce + fetch to resolve
    await waitFor(() => {
      expect(result.current.svgOutput).toBe(TRACE_RESPONSE.output);
    });

    // Only one fetch should have fired (the last debounced one)
    expect(globalThis.fetch).toHaveBeenCalledTimes(1);
  });

  it('passes AbortSignal to fetch and aborts previous requests', async () => {
    let firstSignal: AbortSignal | null | undefined;
    let callCount = 0;
    globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => {
      callCount++;
      if (callCount === 1) {
        firstSignal = opts?.signal;
        // First call hangs (never resolves) to simulate in-flight request
        return new Promise(() => {});
      }
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve(TRACE_RESPONSE),
      });
    });

    const file = new File(['pixels'], 'test.png', { type: 'image/png' });

    const { rerender } = renderHook(
      ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS),
      { initialProps: { params: { epsilon: 1 } as Record<string, unknown> } },
    );

    // Wait for first fetch to fire
    await waitFor(() => {
      expect(callCount).toBe(1);
    });

    expect(firstSignal).toBeDefined();

    // Trigger a new trace — should abort the first
    rerender({ params: { epsilon: 5 } });

    await waitFor(() => {
      expect(callCount).toBe(2);
    });

    // The first request's signal should be aborted
    expect(firstSignal!.aborted).toBe(true);
  });

  it('routes SVG files to /engine/simplify instead of /engine/trace', async () => {
    globalThis.fetch = mockFetchOk(TRACE_RESPONSE);
    const svgFile = new File(['<svg></svg>'], 'logo.svg', {
      type: 'image/svg+xml',
    });

    const { result } = renderHook(() =>
      useDebouncedTrace(svgFile, 'sign', { epsilon: 2 }, DEBOUNCE_MS),
    );

    await waitFor(() => {
      expect(result.current.svgOutput).toBeTruthy();
    });

    const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
    expect(url).toBe('/engine/simplify');
  });

  it('calls /engine/trace for raster images', async () => {
    globalThis.fetch = mockFetchOk(TRACE_RESPONSE);
    const file = new File(['pixels'], 'photo.png', { type: 'image/png' });

    const { result } = renderHook(() =>
      useDebouncedTrace(file, 'sign', { epsilon: 2.5 }, DEBOUNCE_MS),
    );

    await waitFor(() => {
      expect(result.current.svgOutput).toBeTruthy();
    });

    const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
    expect(url).toBe('/engine/trace');
  });

  it('sets error state on fetch failure', async () => {
    globalThis.fetch = mockFetchFail(500, 'Internal Server Error');
    const file = new File(['pixels'], 'test.png', { type: 'image/png' });

    const { result } = renderHook(() =>
      useDebouncedTrace(file, 'sign', { epsilon: 2 }, DEBOUNCE_MS),
    );

    await waitFor(() => {
      expect(result.current.error).toBeTruthy();
    }, { timeout: 2000 });

    expect(result.current.isLoading).toBe(false);
    expect(result.current.svgOutput).toBeNull();
  });

  it('cleans up on unmount (aborts in-flight and clears timeout)', async () => {
    globalThis.fetch = mockFetchOk(TRACE_RESPONSE);
    const file = new File(['pixels'], 'test.png', { type: 'image/png' });

    const { unmount } = renderHook(() =>
      useDebouncedTrace(file, 'sign', { epsilon: 2 }, 500),
    );

    // Unmount before the 500ms debounce fires
    unmount();

    // Give time for any unexpected fetch to fire
    await act(async () => {
      await new Promise((r) => setTimeout(r, 600));
    });

    expect(globalThis.fetch).not.toHaveBeenCalled();
  });
});
