/**
 * Phase 2 adversarial — small-scale DDoS / oversized-body tests. These are
 * NOT real load tests. We just verify that obvious abuse is rate-limited
 * or rejected gracefully without crashing the backend.
 */
import { test, expect } from '../../fixtures/test';
import { mintSseTicket } from '../../helpers/sse';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';

test.describe('Adversarial — small-scale abuse', () => {
  // Note: the truncate auto-fixture resets every rate-limit toggle back to false
  // before each test, so we re-enable in beforeEach (not beforeAll).
  test.beforeEach(async ({ api, adminToken }) => {
    await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
  });

  test('a /join flood from one IP is caught by the per-IP ceiling', async ({ api, adminToken }) => {
    // This used to assert that 20 joins from one IP produced 429s under a 5/min per-IP
    // bucket. That "protection" was the bug: at a venue every guest shares one public IP,
    // so it turned real arriving guests away (see 01-auth/rate-limit-shared-nat). The
    // anti-spam bucket is now per (ip, name); what remains per-IP is a loose ceiling whose
    // job is only to bound raw volume. Squeeze the ceiling so a flood is reproducible here
    // without firing 60+ requests.
    await api.patchConfig(adminToken, { join_ip_rate_per_min: '5' });

    const requests = Array.from({ length: 20 }, (_, i) =>
      fetch(`${BASE}/api/v1/join`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ display_name: `Flood${i}_${Date.now()}` }),
      })
    );
    const statuses = (await Promise.all(requests)).map((r) => r.status);
    // Ceiling of 5 → the excess must be shed.
    expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
    // Server stays up — at least one succeeded.
    expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
  });

  // The comment body cap lives in [backend/src/handlers/social.rs] `add_comment`:
  //   if text_chars == 0 || text_chars > 500 → 400
  // It must be exercised against a REAL upload: the handler looks the upload up (and
  // 404s) *before* it reaches the length check, so posting to a non-existent id proves
  // nothing about the cap.
  async function postComment(jwt: string, uploadId: string, body: string) {
    return fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ body }),
    });
  }

  test('comment body over the 500-char cap is rejected with 400', async ({ guest }) => {
    const g = await guest('LongComment');
    const uploadId = await seedUpload(g.jwt);

    const res = await postComment(g.jwt, uploadId, 'A'.repeat(501));
    expect(res.status, '501 chars must be rejected by the length cap').toBe(400);
    const json: any = await res.json().catch(() => ({}));
    expect((json.message ?? '').toLowerCase()).toMatch(/500 zeichen/);
  });

  test('comment body exactly at the 500-char cap is accepted', async ({ guest }) => {
    const g = await guest('MaxComment');
    const uploadId = await seedUpload(g.jwt);

    // The boundary must be inclusive — otherwise the "cap" is really 499 and the
    // rejection test above would also pass on an off-by-one implementation.
    const res = await postComment(g.jwt, uploadId, 'A'.repeat(500));
    expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
  });

  test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
    guest,
  }) => {
    const g = await guest('BigComment');
    const uploadId = await seedUpload(g.jwt);
    const huge = 'A'.repeat(10 * 1024 * 1024);

    const res = await postComment(g.jwt, uploadId, huge);
    // This asserts ONLY what it can prove: a 10 MB JSON body is refused somewhere on the
    // path (Caddy's request-body limit → 502/413, or the backend's own body limit → 413,
    // or the 500-char cap if it does get through → 400). The upload exists, so a 404 here
    // would be a bug, and a 201 would mean we stored a 10 MB comment.
    expect([400, 413, 502]).toContain(res.status);
  });

  test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
    const g = await guest('SseFlood');
    // The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
    // not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
    // (These streams must be held open concurrently, so we can't use the openStream
    // helper which opens-and-aborts a single stream.)
    const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));

    const controllers = tickets.map(() => new AbortController());
    const requests = tickets.map((ticket, i) =>
      fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
        signal: controllers[i].signal,
      })
    );
    const responses = await Promise.all(requests);
    // One session may hold only MAX_TICKETS_PER_SESSION (4) live tickets — enough for a guest with
    // a couple of tabs open, deliberately not enough for a reconnect loop to accumulate. Minting a
    // 5th evicts the oldest, so most of these ten tickets are already dead when their stream opens
    // and the server answers 401. That IS the cap working; this used to allow only [200, 429] and
    // so failed on the very defence it was written to exercise.
    //
    // What must hold is that the server sheds the flood deliberately rather than falling over: no
    // 5xx, and the surviving tickets still get their stream.
    const statuses = responses.map((r) => r.status);
    for (const s of statuses) {
      expect([200, 401, 429], `unexpected status from the stream flood: ${statuses}`).toContain(s);
    }
    // EXACTLY four, not merely "at least one". The cap is a known constant, so asserting a
    // bound this loose would still pass if it were tightened to 1 (a guest with two tabs loses
    // their live feed) or if eviction kept the OLDEST ticket instead of the newest (every
    // reconnect throws away the ticket it just minted — a permanently dead feed for that guest).
    expect(
      statuses.filter((s) => s === 200).length,
      `exactly MAX_TICKETS_PER_SESSION streams should survive, got: ${statuses}`
    ).toBe(4);
    // Tear them all down so the next test doesn't see leaked connections.
    controllers.forEach((c) => c.abort());

    // Sanity: a new request still works.
    const ping = await fetch(`${BASE}/api/v1/me/context`, {
      headers: { Authorization: `Bearer ${g.jwt}` },
    });
    expect(ping.status).toBe(200);
  });

  test('malformed JSON in /join is rejected with 400, not 500', async () => {
    const res = await fetch(`${BASE}/api/v1/join`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: '{"display_name":',
    });
    expect([400, 422]).toContain(res.status);
    expect(res.status).not.toBe(500);
  });
});
