import { describe, expect, it, vi } from 'vitest';
import { SlidingWindowLimiter } from '../rate-limit';
import { FEEDBACK_MAX_BYTES } from './feedback';
import { FEEDBACK_ROUND_MAX_AGE_MS, handleFeedback, type FeedbackDeps } from './feedback-route';

const ROOM = '24c4db66-51c4-40c1-b79e-f16611e18369';
const ROUND = '0f9b7a52-2c4e-4c8e-9a51-3f0b6f2d7c11';
const VENUE = '05af25e2-52f7-44b5-840c-6a7a159771be';
const NOW = 1_800_000_000_000;

function deps(patch: Partial<FeedbackDeps> = {}): FeedbackDeps & { record: ReturnType<typeof vi.fn> } {
  const record = vi.fn(async () => undefined);
  return {
    now: NOW,
    clientKey: 'ip-hash-1',
    perClient: new SlidingWindowLimiter(100, 60_000),
    global: new SlidingWindowLimiter(1_000, 60_000),
    findVenue: async (roomId, roundId) => (roomId === ROOM && roundId === ROUND ? VENUE : null),
    record,
    ...patch,
  } as FeedbackDeps & { record: ReturnType<typeof vi.fn> };
}

function post(body: unknown, headers: Record<string, string | null> = {}): Request {
  const text = typeof body === 'string' ? body : JSON.stringify(body);
  const h = new Headers({ 'content-type': 'application/json', 'content-length': String(new TextEncoder().encode(text).byteLength), 'sec-fetch-site': 'same-origin' });
  for (const [k, v] of Object.entries(headers)) {
    if (v === null) h.delete(k);
    else h.set(k, v);
  }
  return new Request('http://localhost/api/feedback', { method: 'POST', headers: h, body: text });
}

const vote = { roomId: ROOM, roundId: ROUND, fair: true };

describe('POST /api/feedback', () => {
  it('stores a valid vote against the room’s venue (no IP, no device) and answers 204', async () => {
    const d = deps();
    const res = await handleFeedback(post(vote), d);
    expect(res.status).toBe(204);
    expect(d.record).toHaveBeenCalledWith({ venueId: VENUE, roundId: ROUND, fair: true });
  });

  it('looks the round up only within the recent window', async () => {
    const findVenue = vi.fn(async () => VENUE);
    await handleFeedback(post({ ...vote, fair: false }), deps({ findVenue }));
    expect(findVenue).toHaveBeenCalledWith(ROOM, ROUND, NOW - FEEDBACK_ROUND_MAX_AGE_MS);
  });

  it('refuses bodies without a declared length or over 1 KB before reading them', async () => {
    const d = deps();
    expect((await handleFeedback(post(vote, { 'content-length': null }), d)).status).toBe(411);
    expect((await handleFeedback(post(vote, { 'content-length': String(FEEDBACK_MAX_BYTES + 1) }), d)).status).toBe(413);
    expect(d.record).not.toHaveBeenCalled();
  });

  it('refuses a body larger than it declared', async () => {
    const big = JSON.stringify({ ...vote, pad: 'x'.repeat(FEEDBACK_MAX_BYTES * 2) });
    expect((await handleFeedback(post(big, { 'content-length': '200' }), deps())).status).toBe(413);
  });

  it('rejects cross-site posts, non-JSON and invalid payloads', async () => {
    const d = deps();
    expect((await handleFeedback(post(vote, { 'sec-fetch-site': 'cross-site' }), d)).status).toBe(403);
    expect((await handleFeedback(post(vote, { 'content-type': 'text/plain' }), d)).status).toBe(415);
    expect((await handleFeedback(post('{not json'), d)).status).toBe(400);
    expect((await handleFeedback(post({ ...vote, fair: 'yes' }), d)).status).toBe(400);
    expect((await handleFeedback(post({ ...vote, roundId: 'r1' }), d)).status).toBe(400);
    expect((await handleFeedback(post({ ...vote, extra: 1 }), d)).status).toBe(400);
    expect(d.record).not.toHaveBeenCalled();
  });

  it('answers 404 for an unknown or inactive room, or a round not played there', async () => {
    const d = deps();
    expect((await handleFeedback(post({ ...vote, roomId: '923da032-5c1f-4722-ada2-d0dee78dbe20' }), d)).status).toBe(404);
    expect(d.record).not.toHaveBeenCalled();
  });

  it('rate-limits per client before reading the body', async () => {
    const d = deps({ perClient: new SlidingWindowLimiter(2, 60_000) });
    expect((await handleFeedback(post(vote), d)).status).toBe(204);
    expect((await handleFeedback(post('{not json'), d)).status).toBe(400);
    const limited = await handleFeedback(post(vote), d);
    expect(limited.status).toBe(429);
    expect(limited.headers.get('retry-after')).toBe('60');
    // Another address still gets through.
    expect((await handleFeedback(post(vote), { ...d, clientKey: 'ip-hash-2' })).status).toBe(204);
  });

  it('counts only storable votes against the global ceiling', async () => {
    const global = new SlidingWindowLimiter(1, 60_000);
    const d = deps({ global });
    expect((await handleFeedback(post({ ...vote, roundId: 'nope' }), d)).status).toBe(400);
    expect((await handleFeedback(post({ ...vote, roomId: '923da032-5c1f-4722-ada2-d0dee78dbe20' }), d)).status).toBe(404);
    expect((await handleFeedback(post(vote), d)).status).toBe(204);
    expect((await handleFeedback(post(vote), { ...d, clientKey: 'ip-hash-3' })).status).toBe(429);
    expect(d.record).toHaveBeenCalledTimes(1);
  });

  it('answers 503 when the database fails', async () => {
    const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
    const res = await handleFeedback(post(vote), deps({ record: async () => Promise.reject(new Error('db down')) }));
    expect(res.status).toBe(503);
    error.mockRestore();
  });
});
