import { describe, expect, it } from 'vitest';
import {
  OpenWAClient,
  OpenWAApiError,
  OpenWAAuthError,
  OpenWAForbiddenError,
  OpenWANotFoundError,
  OpenWAConflictError,
  OpenWARateLimitError,
  OpenWANotImplementedError,
  OpenWAServiceUnavailableError,
  OpenWATimeoutError,
} from '../src';
import type { FetchLike } from '../src';
import { MockTransport } from './helpers';

function client(transport: MockTransport): OpenWAClient {
  return new OpenWAClient({
    baseUrl: 'http://localhost:2785',
    apiKey: 'owa_k1_test',
    fetch: transport.asFetch(),
  });
}

describe('OpenWAClient', () => {
  it('requires baseUrl and apiKey', () => {
    expect(() => new OpenWAClient({ baseUrl: '', apiKey: 'x' })).toThrow();
    expect(() => new OpenWAClient({ baseUrl: 'http://x', apiKey: '' })).toThrow();
  });

  it('sends the API key as X-API-Key and JSON content type', async () => {
    const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
    await client(t).sessions.list();
    expect(t.lastCall!.headers['x-api-key']).toBe('owa_k1_test');
    expect(t.lastCall!.headers['content-type']).toBe('application/json');
  });

  it('strips a trailing slash from baseUrl', async () => {
    const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
    const c = new OpenWAClient({ baseUrl: 'http://localhost:2785/', apiKey: 'k', fetch: t.asFetch() });
    await c.sessions.list();
    expect(t.lastCall!.url).toBe('http://localhost:2785/api/sessions');
  });

  it('does not auto-follow redirects (passes redirect: manual to fetch)', async () => {
    // Auto-following a redirect would re-send X-API-Key to the redirect target,
    // potentially a different origin. The SDK must not follow silently.
    let seenInit: RequestInit | undefined;
    const recordingFetch: FetchLike = async (_url, init) => {
      seenInit = init as RequestInit;
      return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
    };
    const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: recordingFetch });
    await c.health.check();
    expect(seenInit?.redirect).toBe('manual');
  });

  it('treats an unfollowed redirect (3xx) as an error', async () => {
    // Redirects are never followed, so a 3xx is not a usable response — it must throw, keeping the
    // JS transport aligned with the Python and PHP SDKs (which now also error on >= 300).
    const redirectingFetch: FetchLike = async () =>
      new Response('{"redirected":true}', { status: 302, headers: { location: 'http://evil.example/x' } });
    const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: redirectingFetch });
    await expect(c.sessions.list()).rejects.toThrow();
  });

  it('surfaces a real opaque unfollowed redirect (status 0) as a clear OpenWAApiError', async () => {
    // With `redirect: 'manual'` the runtime returns an opaque response with status 0 (not a 3xx);
    // this is the actual shape the no-redirect guard produces, and it must throw a clear error.
    const opaqueRedirectFetch: FetchLike = async () => Response.error();
    const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: opaqueRedirectFetch });
    const err = await c.sessions.list().catch((e: unknown) => e);
    expect(err).toBeInstanceOf(OpenWAApiError);
    expect((err as OpenWAApiError).status).toBe(0);
    expect((err as OpenWAApiError).message).toMatch(/redirect/i);
  });

  it('percent-encodes path segments but keeps @ in JIDs readable', async () => {
    const t = new MockTransport().on('GET', /\/history$/, { body: [] });
    await client(t).messages.history('s', 'a@c.us');
    expect(t.lastCall!.url).toContain('/messages/a@c.us/history'); // @ preserved
    const t2 = new MockTransport().on('GET', /\/history$/, { body: [] });
    await client(t2).messages.history('s', 'weird/id#x');
    expect(t2.lastCall!.url).toContain('weird%2Fid%23x'); // path-breaking chars encoded
  });

  it('serializes query params and skips null/undefined', async () => {
    const t = new MockTransport().on('GET', /\/messages/, { body: [] });
    await client(t).messages.list('s1', { chatId: 'a@c.us', from: undefined, limit: 10 });
    expect(t.lastCall!.url).toContain('chatId=a%40c.us');
    expect(t.lastCall!.url).toContain('limit=10');
    expect(t.lastCall!.url).not.toContain('from=');
  });

  it('maps a 404 to OpenWANotFoundError with parsed body', async () => {
    const t = new MockTransport().on('GET', '/api/sessions/missing', {
      status: 404,
      body: { statusCode: 404, message: 'Session not found', error: 'Not Found' },
    });
    await expect(client(t).sessions.get('missing')).rejects.toBeInstanceOf(OpenWANotFoundError);
    await expect(client(t).sessions.get('missing')).rejects.toMatchObject({ status: 404 });
  });

  it('maps a 503 to OpenWAServiceUnavailableError', async () => {
    // The gateway answers 503 when the engine never confirmed an operation. It is the only typed error
    // here that is worth retrying, and it used to fall through to the base class while 501 — which is
    // permanent — had a subclass of its own.
    const t = new MockTransport().on('POST', '/api/sessions/s1/messages/send-text', {
      status: 503,
      body: { statusCode: 503, message: 'WhatsApp did not answer in time', error: 'Service Unavailable' },
    });
    await expect(client(t).messages.sendText('s1', { chatId: 'c@c.us', text: 'x' })).rejects.toBeInstanceOf(
      OpenWAServiceUnavailableError,
    );
  });

  it('exposes all expected resource properties', () => {
    const c = client(new MockTransport());
    for (const r of [
      'sessions',
      'messages',
      'contacts',
      'groups',
      'webhooks',
      'chats',
      'status',
      'health',
      'labels',
      'channels',
      'catalog',
      'templates',
      'search',
      'profile',
      'calls',
    ]) {
      expect(c).toHaveProperty(r);
    }
  });

  it('treats 204 as a null result', async () => {
    const t = new MockTransport().on('DELETE', '/api/sessions/x', { status: 204 });
    await expect(client(t).sessions.delete('x')).resolves.toBeNull();
  });

  it('OpenWAApiError.fromResponse parses the NestJS envelope', async () => {
    const t = new MockTransport().on('POST', /send-text/, {
      status: 409,
      body: { statusCode: 409, message: 'Engine not ready', error: 'Conflict' },
    });
    await expect(client(t).messages.sendText('s', { chatId: 'a@c.us', text: 'hi' })).rejects.toBeInstanceOf(
      OpenWAApiError,
    );
  });

  // A stock production gateway runs the ValidationPipe with `disableErrorMessages`, so NestJS omits
  // `error` and answers `{ statusCode, message }`. Every case above sends the three-key development
  // shape, which is why the suite stayed green while this body rendered as "[object Object]".
  it('parses the envelope a production gateway actually sends, with no `error` key', async () => {
    const t = new MockTransport().on('POST', '/api/sessions', {
      status: 400,
      body: { message: 'Bad Request', statusCode: 400 },
    });
    const err = await client(t)
      .sessions.create({ name: 'x' })
      .catch((e: unknown) => e);

    expect(err).toBeInstanceOf(OpenWAApiError);
    expect((err as OpenWAApiError).message).toContain('Bad Request');
    expect((err as OpenWAApiError).message).not.toContain('[object Object]');
    expect((err as OpenWAApiError).status).toBe(400);
    // No `error` key was sent, so there is no kind to report — undefined, not a stringified object.
    expect((err as OpenWAApiError).errorKind).toBeUndefined();
  });

  it('still reads `error` as the kind when the gateway sends one', async () => {
    const t = new MockTransport().on('POST', '/api/sessions', {
      status: 400,
      body: { message: ['name must be a string'], error: 'Bad Request', statusCode: 400 },
    });
    const err = await client(t)
      .sessions.create({ name: 'x' })
      .catch((e: unknown) => e);

    expect((err as OpenWAApiError).errorKind).toBe('Bad Request');
    expect((err as OpenWAApiError).message).toContain('name must be a string');
  });

  it('maps each status code to its typed error subclass', async () => {
    const cases: Array<[number, new (...a: never[]) => OpenWAApiError]> = [
      [401, OpenWAAuthError],
      [403, OpenWAForbiddenError],
      [404, OpenWANotFoundError],
      [409, OpenWAConflictError],
      [429, OpenWARateLimitError],
      [501, OpenWANotImplementedError],
    ];
    for (const [status, cls] of cases) {
      const t = new MockTransport().on('GET', '/api/sessions', {
        status,
        body: { statusCode: status, message: 'x', error: 'E' },
      });
      await expect(client(t).sessions.list()).rejects.toBeInstanceOf(cls);
    }
  });

  it('falls back to the generic OpenWAApiError (with .status) for an unmapped status', async () => {
    const t = new MockTransport().on('GET', '/api/sessions', {
      status: 418,
      body: { statusCode: 418, message: 'teapot', error: 'Teapot' },
    });
    await expect(client(t).sessions.list()).rejects.toMatchObject({ status: 418 });
    await expect(client(t).sessions.list()).rejects.toBeInstanceOf(OpenWAApiError);
  });

  it('throws OpenWATimeoutError when the request aborts', async () => {
    const abortingFetch: FetchLike = async () => {
      const e = new Error('aborted');
      e.name = 'AbortError';
      throw e;
    };
    const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: abortingFetch });
    await expect(c.sessions.list()).rejects.toBeInstanceOf(OpenWATimeoutError);
  });

  it('keeps the timeout armed while reading a stalled response body', async () => {
    const stalledBodyFetch: FetchLike = async (_url, init) => {
      const signal = init?.signal;
      const body = new ReadableStream<Uint8Array>({
        start(controller) {
          signal?.addEventListener('abort', () => {
            const error = new Error('body aborted');
            error.name = 'AbortError';
            controller.error(error);
          });
        },
      });
      return new Response(body, { status: 200 });
    };
    const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', timeoutMs: 5, fetch: stalledBodyFetch });

    await expect(c.sessions.list()).rejects.toBeInstanceOf(OpenWATimeoutError);
  });

  it('keeps X-API-Key winning over defaultHeaders', async () => {
    const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
    const c = new OpenWAClient({
      baseUrl: 'http://x',
      apiKey: 'REAL',
      defaultHeaders: { 'X-API-Key': 'EVIL', 'X-Trace': 'keep' },
      fetch: t.asFetch(),
    });
    await c.sessions.list();
    expect(t.lastCall!.headers['x-api-key']).toBe('REAL');
    expect(t.lastCall!.headers['x-trace']).toBe('keep');
  });

  it('keeps the JSON Content-Type winning over a defaultHeaders override', async () => {
    const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
    const c = new OpenWAClient({
      baseUrl: 'http://x',
      apiKey: 'k',
      defaultHeaders: { 'Content-Type': 'text/plain', 'X-Trace': 'keep' },
      fetch: t.asFetch(),
    });
    await c.sessions.list();
    // JSON wins (matches the Python/PHP SDKs), but an unrelated default header is still preserved.
    expect(t.lastCall!.headers['content-type']).toBe('application/json');
    expect(t.lastCall!.headers['x-trace']).toBe('keep');
  });
});
