import { afterEach, describe, expect, it } from 'vitest';
import type WebSocket from 'ws';
import {
  closeWebSocket,
  expectStringOrNull,
  extractAssetPath,
  fetchFromServer,
  openWebSocket,
  serverUrl,
} from './helpers.js';

const openSockets = new Set<WebSocket>();

afterEach(async () => {
  await Promise.all(Array.from(openSockets, (socket) => closeWebSocket(socket)));
  openSockets.clear();
});

describe('shared server API conformance', () => {
  it('serves HTML for app routes, preserves API 404s, and sets HTML/JS/CSS content types', async () => {
    const root = await fetchFromServer('/');
    expect(root.status).toBe(200);
    expect(root.headers.get('content-type')).toContain('text/html');

    const rootHtml = await root.text();
    expect(rootHtml).toContain('<!DOCTYPE html>');
    expect(rootHtml).toContain('<div id="app"></div>');

    const scriptPath = extractAssetPath(rootHtml, 'script');
    const stylesheetPath = extractAssetPath(rootHtml, 'stylesheet');

    const spaFallback = await fetchFromServer('/nonexistent-path');
    expect(spaFallback.status).toBe(200);
    expect(spaFallback.headers.get('content-type')).toContain('text/html');
    expect(await spaFallback.text()).toContain('<div id="app"></div>');

    const missingApi = await fetchFromServer('/api/nonexistent');
    expect(missingApi.status).toBe(404);

    const script = await fetchFromServer(scriptPath);
    expect(script.status).toBe(200);
    expect(script.headers.get('content-type')).toMatch(/javascript/i);

    const stylesheet = await fetchFromServer(stylesheetPath);
    expect(stylesheet.status).toBe(200);
    expect(stylesheet.headers.get('content-type')).toContain('text/css');
  });

  it('returns runtime config with the expected nullable string fields', async () => {
    const response = await fetchFromServer('/api/runtime-config');
    expect(response.status).toBe(200);
    expect(response.headers.get('content-type')).toContain('application/json');

    const body = (await response.json()) as Record<string, unknown>;
    expect(body).toHaveProperty('trayWorkerBaseUrl');
    expect(body).toHaveProperty('trayJoinUrl');
    expectStringOrNull(body['trayWorkerBaseUrl']);
    expectStringOrNull(body['trayJoinUrl']);
  });

  it('returns a non-empty, valid trayWorkerBaseUrl string', async () => {
    const response = await fetchFromServer('/api/runtime-config');
    expect(response.status).toBe(200);

    const body = (await response.json()) as Record<string, unknown>;
    const url = body['trayWorkerBaseUrl'];
    expect(typeof url).toBe('string');
    expect((url as string).length).toBeGreaterThan(0);

    const parsed = new URL(url as string);
    expect(parsed.protocol).toBe('https:');
  });

  it('serves the OAuth callback page and relays/stores pending OAuth results', async () => {
    const drain = await fetchFromServer('/api/oauth-result');
    expect([200, 204]).toContain(drain.status);

    const callback = await fetchFromServer('/auth/callback?code=test-code&state=test-state');
    expect(callback.status).toBe(200);
    expect(callback.headers.get('content-type')).toContain('text/html');
    const callbackHtml = await callback.text();
    expect(callbackHtml).toContain('Completing login');
    expect(callbackHtml).toContain('/api/oauth-result');

    const redirectUrl = `https://example.test/oauth/callback#state=state-${Date.now()}`;
    const post = await fetchFromServer('/api/oauth-result', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ redirectUrl, error: 'access_denied', code: 'ignored-by-server' }),
    });
    expect(post.status).toBe(200);
    await expect(post.json()).resolves.toEqual({ ok: true });

    const firstPoll = await fetchFromServer('/api/oauth-result');
    expect(firstPoll.status).toBe(200);
    await expect(firstPoll.json()).resolves.toEqual({ redirectUrl, error: 'access_denied' });

    const secondPoll = await fetchFromServer('/api/oauth-result');
    expect(secondPoll.status).toBe(204);
  });

  it('proxies local requests through /api/fetch-proxy', async () => {
    const missingHeader = await fetchFromServer('/api/fetch-proxy', { method: 'POST' });
    expect(missingHeader.status).toBe(400);
    // Proxy infrastructure errors must be tagged so SecureFetch clients
    // can distinguish them from upstream 4xx/5xx that should flow through.
    expect(missingHeader.headers.get('x-proxy-error')).toBe('1');

    const proxied = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
      },
    });
    expect(proxied.status).toBe(200);
    expect(proxied.headers.get('transfer-encoding')).toBeNull();
    expect(proxied.headers.get('content-encoding')).toBeNull();
    expect(proxied.headers.get('www-authenticate')).toBeNull();
    // Successful proxied responses must NOT carry the proxy-error marker.
    expect(proxied.headers.get('x-proxy-error')).toBeNull();

    const body = (await proxied.json()) as Record<string, unknown>;
    expect(body).toHaveProperty('trayWorkerBaseUrl');
  });

  it('forwards upstream 4xx without the proxy-error marker', async () => {
    // Mount a tiny upstream that always replies 400 with an OAuth-style
    // JSON body — this is exactly the shape Google's token endpoint uses
    // for invalid_client. The proxy must pass status + body through and
    // must NOT set X-Proxy-Error, so SecureFetch returns the response
    // unchanged instead of throwing "[object Object]".
    const { createServer } = await import('node:http');
    const upstream = createServer((_req, res) => {
      res.statusCode = 400;
      res.setHeader('Content-Type', 'application/json');
      res.end(
        JSON.stringify({
          error: 'invalid_client',
          error_description: 'The OAuth client was not found.',
        })
      );
    });

    await new Promise<void>((resolve) => upstream.listen(0, resolve));
    const port = (upstream.address() as import('node:net').AddressInfo).port;

    try {
      const proxied = await fetchFromServer('/api/fetch-proxy', {
        method: 'POST',
        headers: {
          'x-target-url': `http://localhost:${port}/token`,
          'content-type': 'application/x-www-form-urlencoded',
        },
        body: 'client_id=fake&grant_type=refresh_token',
      });

      expect(proxied.status).toBe(400);
      // Crucially: no proxy-error marker → upstream 4xx flows through.
      expect(proxied.headers.get('x-proxy-error')).toBeNull();

      const body = (await proxied.json()) as { error?: string };
      expect(body.error).toBe('invalid_client');
    } finally {
      await new Promise<void>((resolve) => upstream.close(() => resolve()));
    }
  });

  it('tags upstream-fetch failures with X-Proxy-Error: 1', async () => {
    // Stand up an upstream that closes the connection before any response
    // is written. Node's fetch resolves this as a network error which the
    // proxy converts to a 502 — that 502 is a proxy infrastructure failure
    // (we never got an upstream answer) and so must carry the marker.
    const { createServer } = await import('node:http');
    const upstream = createServer((req) => {
      req.socket.destroy();
    });
    await new Promise<void>((resolve) => upstream.listen(0, resolve));
    const port = (upstream.address() as import('node:net').AddressInfo).port;

    try {
      const proxied = await fetchFromServer('/api/fetch-proxy', {
        method: 'GET',
        headers: {
          'x-target-url': `http://localhost:${port}/never`,
        },
      });

      expect(proxied.status).toBe(502);
      expect(proxied.headers.get('x-proxy-error')).toBe('1');

      const body = (await proxied.json()) as { error?: string };
      expect(body.error).toMatch(/Proxy fetch failed/);
    } finally {
      await new Promise<void>((resolve) => upstream.close(() => resolve()));
    }
  });

  it('accepts X-Proxy-Cookie without errors and does not forward it in the response', async () => {
    const proxied = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
        'x-proxy-cookie': 'session=abc123',
      },
    });
    expect(proxied.status).toBe(200);
    // X-Proxy-Cookie is a request-only transport header — it must not appear in the response
    expect(proxied.headers.get('x-proxy-cookie')).toBeNull();

    const responseBody = (await proxied.json()) as Record<string, unknown>;
    expect(responseBody).toHaveProperty('trayWorkerBaseUrl');
  });

  it('does not include raw set-cookie in proxy responses', async () => {
    const proxied = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
      },
    });
    expect(proxied.status).toBe(200);
    // set-cookie is explicitly stripped from proxy responses (it would be
    // transported via X-Proxy-Set-Cookie if the upstream had set any)
    expect(proxied.headers.get('set-cookie')).toBeNull();
  });

  it('does not include X-Proxy-Set-Cookie when upstream sets no cookies', async () => {
    const proxied = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
      },
    });
    expect(proxied.status).toBe(200);
    // When upstream response has no Set-Cookie, the transport header should be absent
    expect(proxied.headers.get('x-proxy-set-cookie')).toBeNull();
  });

  it('transports Set-Cookie from upstream as X-Proxy-Set-Cookie JSON array', async () => {
    const { createServer } = await import('node:http');
    const upstream = createServer((_req, res) => {
      res.setHeader('Set-Cookie', ['session=abc123; Path=/', 'theme=dark; Path=/']);
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ ok: true }));
    });

    await new Promise<void>((resolve) => upstream.listen(0, resolve));
    const port = (upstream.address() as import('node:net').AddressInfo).port;

    try {
      const proxied = await fetchFromServer('/api/fetch-proxy', {
        method: 'GET',
        headers: {
          'x-target-url': `http://localhost:${port}/`,
        },
      });

      expect(proxied.status).toBe(200);
      // Raw set-cookie must be stripped from the proxy response
      expect(proxied.headers.get('set-cookie')).toBeNull();

      // The transport header must carry the cookies as a JSON array
      const transportHeader = proxied.headers.get('x-proxy-set-cookie');
      expect(transportHeader).not.toBeNull();
      const cookies: string[] = JSON.parse(transportHeader!);
      expect(cookies).toBeInstanceOf(Array);
      expect(cookies).toHaveLength(2);
      expect(cookies[0]).toContain('session=abc123');
      expect(cookies[1]).toContain('theme=dark');
    } finally {
      upstream.close();
    }
  });

  it('accepts X-Proxy-Proxy-Authorization without errors', async () => {
    const proxied = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
        'x-proxy-proxy-authorization': 'Basic xyz',
      },
    });
    // The proxy should restore X-Proxy-Proxy-Authorization as Proxy-Authorization
    // for the upstream request. Since the upstream is our own server, it doesn't
    // care about that header — we just verify the proxy doesn't error.
    expect(proxied.status).toBe(200);
    // The transport header itself should not appear in the response
    expect(proxied.headers.get('x-proxy-proxy-authorization')).toBeNull();

    const responseBody = (await proxied.json()) as Record<string, unknown>;
    expect(responseBody).toHaveProperty('trayWorkerBaseUrl');
  });

  it('strips localhost Origin from proxy requests by default', async () => {
    const res = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
        origin: 'http://localhost:5710',
      },
    });
    expect(res.status).toBe(200);
    // The proxy should have stripped the localhost origin before forwarding
    // We can't directly verify what was sent upstream, but we verify the proxy doesn't error
  });

  it('forwards X-Proxy-Origin as Origin to upstream', async () => {
    const res = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
        'x-proxy-origin': 'https://suno.com',
      },
    });
    expect(res.status).toBe(200);
    // Verify X-Proxy-Origin doesn't leak into response
    expect(res.headers.get('x-proxy-origin')).toBeNull();
  });

  it('forwards X-Proxy-Referer as Referer to upstream', async () => {
    const res = await fetchFromServer('/api/fetch-proxy', {
      method: 'GET',
      headers: {
        'x-target-url': serverUrl('/api/runtime-config'),
        'x-proxy-referer': 'https://suno.com/create',
      },
    });
    expect(res.status).toBe(200);
    expect(res.headers.get('x-proxy-referer')).toBeNull();
  });

  it('transports Origin header to upstream via X-Proxy-Origin', async () => {
    const { createServer } = await import('node:http');
    const upstream = createServer((req, res) => {
      res.setHeader('Content-Type', 'application/json');
      res.end(
        JSON.stringify({
          origin: req.headers['origin'] || null,
          referer: req.headers['referer'] || null,
        })
      );
    });

    await new Promise<void>((resolve) => upstream.listen(0, resolve));
    const port = (upstream.address() as import('node:net').AddressInfo).port;

    try {
      const res = await fetchFromServer('/api/fetch-proxy', {
        method: 'GET',
        headers: {
          'x-target-url': `http://localhost:${port}/`,
          'x-proxy-origin': 'https://suno.com',
          'x-proxy-referer': 'https://suno.com/create',
        },
      });
      expect(res.status).toBe(200);
      const body = await res.json();
      expect(body.origin).toBe('https://suno.com');
      expect(body.referer).toBe('https://suno.com/create');
    } finally {
      upstream.close();
    }
  });

  it('returns webhook CORS headers for preflight requests', async () => {
    const response = await fetchFromServer('/webhooks/test-id', { method: 'OPTIONS' });
    expect(response.status).toBe(204);
    expect(response.headers.get('access-control-allow-origin')).toBe('*');
    expect(response.headers.get('access-control-allow-methods')).toContain('POST');
    expect(response.headers.get('access-control-allow-headers')).toContain('Content-Type');
  });

  it('accepts lick websocket connections, handles request/response traffic, and broadcasts webhook events', async () => {
    const { socket, nextMessage } = await openWebSocket('/licks-ws');
    openSockets.add(socket);

    const trayStatusPromise = fetchFromServer('/api/tray-status');
    const request = await nextMessage();
    expect(request['type']).toBe('tray_status');
    expect(typeof request['requestId']).toBe('string');

    socket.send(
      JSON.stringify({
        type: 'response',
        requestId: request['requestId'],
        data: { state: 'connected', joinUrl: 'https://example.test/join' },
      })
    );

    const trayStatus = await trayStatusPromise;
    expect(trayStatus.status).toBe(200);
    await expect(trayStatus.json()).resolves.toEqual({
      state: 'connected',
      joinUrl: 'https://example.test/join',
    });

    const webhookPost = await fetchFromServer('/webhooks/test-id', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ event: 'ping' }),
    });
    expect(webhookPost.status).toBe(200);
    await expect(webhookPost.json()).resolves.toEqual({ ok: true, received: true });

    const broadcast = await nextMessage();
    expect(broadcast).toMatchObject({
      type: 'webhook_event',
      webhookId: 'test-id',
      body: { event: 'ping' },
    });
  });

  it('accepts websocket upgrades on /cdp', async () => {
    const { socket } = await openWebSocket('/cdp');
    openSockets.add(socket);
  });

  it('lists secrets via GET /api/secrets', async () => {
    const list = await fetchFromServer('/api/secrets');
    expect(list.status).toBe(200);
    const entries = (await list.json()) as Array<{ name: string; domains: string[] }>;
    expect(Array.isArray(entries)).toBe(true);
    // Value must never be returned
    for (const entry of entries) {
      expect((entry as Record<string, unknown>)['value']).toBeUndefined();
    }
  });

  it('rejects POST /api/secrets (write route removed)', async () => {
    const res = await fetchFromServer('/api/secrets', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ name: 'INTTEST_BLOCKED', value: 'v', domains: ['d.com'] }),
    });
    // Express returns 404 for unmatched routes
    expect(res.status).toBeGreaterThanOrEqual(400);
  });

  it('rejects DELETE /api/secrets/:name (write route removed)', async () => {
    const res = await fetchFromServer('/api/secrets/INTTEST_BLOCKED', { method: 'DELETE' });
    // Express returns 404 for unmatched routes
    expect(res.status).toBeGreaterThanOrEqual(400);
  });

  it('returns structured responses from lick-backed REST endpoints based on browser connectivity', async () => {
    const endpoints = [
      {
        path: '/api/webhooks',
        assertConnectedBody: (body: unknown) => expect(Array.isArray(body)).toBe(true),
      },
      {
        path: '/api/tray-status',
        assertConnectedBody: (body: unknown) => {
          expect(body && typeof body === 'object').toBe(true);
          expect(typeof (body as Record<string, unknown>)['state']).toBe('string');
          expectStringOrNull((body as Record<string, unknown>)['joinUrl']);
        },
      },
      {
        path: '/api/crontasks',
        assertConnectedBody: (body: unknown) => expect(Array.isArray(body)).toBe(true),
      },
    ];

    for (const { path, assertConnectedBody } of endpoints) {
      const response = await fetchFromServer(path);
      const body = (await response.json()) as Record<string, unknown> | unknown[];

      if (response.status === 503) {
        expect(typeof (body as Record<string, unknown>)['error']).toBe('string');
        continue;
      }

      expect(
        response.status,
        `${path} should either succeed or report browser unavailability`
      ).toBe(200);
      assertConnectedBody(body);
    }
  });
});
