/**
 * Tests for API utilities.
 */

import { describe, it, expect, vi, afterEach } from 'vitest';
import { isAbortError, api } from '../api';

describe('isAbortError', () => {
  it('returns true for AbortError', () => {
    const controller = new AbortController();
    controller.abort();

    // Create an error that mimics fetch abort
    const error = new DOMException('The operation was aborted', 'AbortError');

    expect(isAbortError(error)).toBe(true);
  });

  it('returns true for Error with name AbortError', () => {
    const error = new Error('Request cancelled');
    error.name = 'AbortError';

    expect(isAbortError(error)).toBe(true);
  });

  it('returns false for regular Error', () => {
    const error = new Error('Something went wrong');

    expect(isAbortError(error)).toBe(false);
  });

  it('returns false for TypeError', () => {
    const error = new TypeError('Network failure');

    expect(isAbortError(error)).toBe(false);
  });

  it('returns false for null', () => {
    expect(isAbortError(null)).toBe(false);
  });

  it('returns false for undefined', () => {
    expect(isAbortError(undefined)).toBe(false);
  });

  it('returns false for non-Error objects', () => {
    expect(isAbortError({ message: 'error' })).toBe(false);
    expect(isAbortError('error string')).toBe(false);
    expect(isAbortError(42)).toBe(false);
  });

  it('returns false for Error subclasses with different names', () => {
    class CustomError extends Error {
      constructor() {
        super('Custom error');
        this.name = 'CustomError';
      }
    }

    expect(isAbortError(new CustomError())).toBe(false);
  });
});

describe('fetchJson (via api methods)', () => {
  const mockFetch = vi.fn();

  // Replace global fetch before each test, restore after
  afterEach(() => {
    vi.restoreAllMocks();
  });

  function installMockFetch() {
    global.fetch = mockFetch;
  }

  describe('successful responses', () => {
    it('returns parsed JSON on a successful response', async () => {
      installMockFetch();
      const healthData = {
        status: 'connected',
        radio_connected: true,
        radio_initializing: false,
        connection_info: 'Serial: /dev/ttyUSB0',
        database_size_mb: 1.2,
        oldest_undecrypted_timestamp: null,
      };
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve(healthData),
      });

      const result = await api.getHealth();

      expect(result).toEqual(healthData);
    });

    it('calls fetch with /api prefix', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      await api.getContacts();

      expect(mockFetch).toHaveBeenCalledTimes(1);
      const [url] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/contacts?limit=100&offset=0');
    });

    it('builds repeater advert path endpoint query', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      await api.getRepeaterAdvertPaths(12);

      const [url] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
    });
  });

  describe('error handling', () => {
    it('extracts detail from FastAPI JSON error response', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 503,
        statusText: 'Service Unavailable',
        text: () => Promise.resolve('{"detail": "Radio not connected"}'),
      });

      await expect(api.getHealth()).rejects.toThrow('Radio not connected');
    });

    it('uses raw text when error response is not JSON', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 500,
        statusText: 'Internal Server Error',
        text: () => Promise.resolve('Something broke on the server'),
      });

      await expect(api.getHealth()).rejects.toThrow('Something broke on the server');
    });

    it('uses statusText when error text is empty', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 502,
        statusText: 'Bad Gateway',
        text: () => Promise.resolve(''),
      });

      await expect(api.getHealth()).rejects.toThrow('Bad Gateway');
    });

    it('uses raw text when JSON lacks detail field', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 422,
        statusText: 'Unprocessable Entity',
        text: () => Promise.resolve('{"error": "validation failed"}'),
      });

      await expect(api.getHealth()).rejects.toThrow('{"error": "validation failed"}');
    });
  });

  describe('Content-Type header', () => {
    it('omits Content-Type on GET requests (no body)', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ status: 'connected' }),
      });

      await api.getHealth();

      const [, options] = mockFetch.mock.calls[0];
      expect(options.headers).not.toHaveProperty('Content-Type');
    });

    it('sends Content-Type: application/json on POST requests with body', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ id: 1, text: 'hello' }),
      });

      await api.sendDirectMessage('abc123', 'hello');

      const [, options] = mockFetch.mock.calls[0];
      expect(options.headers).toEqual(
        expect.objectContaining({ 'Content-Type': 'application/json' })
      );
    });

    it('omits Content-Type on POST requests without a body', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () =>
          Promise.resolve({
            contact: null,
            forward_path: { path: '', path_len: 0, path_hash_mode: 0 },
            return_path: { path: '', path_len: 0, path_hash_mode: 0 },
          }),
      });

      await api.requestPathDiscovery('aa'.repeat(32));

      const [, options] = mockFetch.mock.calls[0];
      expect(options.method).toBe('POST');
      expect(options.headers).not.toHaveProperty('Content-Type');
    });
  });

  describe('HTTP methods and body', () => {
    it('sends POST with JSON body for sendDirectMessage', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () =>
          Promise.resolve({
            id: 1,
            type: 'PRIV',
            text: 'hello',
            destination: 'abc123',
          }),
      });

      await api.sendDirectMessage('abc123', 'hello');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/messages/direct');
      expect(options.method).toBe('POST');
      expect(JSON.parse(options.body)).toEqual({
        destination: 'abc123',
        text: 'hello',
      });
    });

    it('sends PATCH with JSON body for updateRadioConfig', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ name: 'NewName' }),
      });

      await api.updateRadioConfig({ name: 'NewName' });

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/radio/config');
      expect(options.method).toBe('PATCH');
      expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
    });

    it('sends PUT with JSON body for setPrivateKey', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ status: 'ok' }),
      });

      await api.setPrivateKey('my-secret-key');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/radio/private-key');
      expect(options.method).toBe('PUT');
      expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
    });

    it('sends POST with JSON body for mesh discovery', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ target: 'repeaters', duration_seconds: 8, results: [] }),
      });

      await api.discoverMesh('repeaters');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/radio/discover');
      expect(options.method).toBe('POST');
      expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
    });

    it('sends DELETE for deleteContact', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ status: 'ok' }),
      });

      await api.deleteContact('pubkey123');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/contacts/pubkey123');
      expect(options.method).toBe('DELETE');
    });

    it('sends POST with flood mode for sendAdvertisement', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ status: 'ok' }),
      });

      await api.sendAdvertisement();

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/radio/advertise');
      expect(options.method).toBe('POST');
      expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
    });

    it('sends POST with zero-hop mode for sendAdvertisement', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ status: 'ok' }),
      });

      await api.sendAdvertisement('zero_hop');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/radio/advertise');
      expect(options.method).toBe('POST');
      expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
    });
  });

  describe('AbortSignal passthrough', () => {
    it('passes signal option through to fetch for getMessages', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      const controller = new AbortController();
      await api.getMessages({ limit: 10 }, controller.signal);

      const [, options] = mockFetch.mock.calls[0];
      expect(options.signal).toBe(controller.signal);
    });

    it('calls fetch without signal when none is provided', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      await api.getMessages({ limit: 10 });

      const [, options] = mockFetch.mock.calls[0];
      expect(options.signal).toBeUndefined();
    });
  });

  describe('api.getMessages query parameter construction', () => {
    it('builds query string with all parameters', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      await api.getMessages({
        limit: 50,
        offset: 10,
        type: 'PRIV',
        conversation_key: 'abc123',
        before: 1700000000,
        before_id: 99,
      });

      const [url] = mockFetch.mock.calls[0];
      expect(url).toContain('/api/messages?');
      expect(url).toContain('limit=50');
      expect(url).toContain('offset=10');
      expect(url).toContain('type=PRIV');
      expect(url).toContain('conversation_key=abc123');
      expect(url).toContain('before=1700000000');
      expect(url).toContain('before_id=99');
    });

    it('builds URL without query string when no params given', async () => {
      installMockFetch();
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve([]),
      });

      await api.getMessages();

      const [url] = mockFetch.mock.calls[0];
      expect(url).toBe('/api/messages');
    });
  });
});
