/**
 * Server half of the "Was that round fair?" prompt (POST /api/feedback). Kept apart from the route
 * file so it can be unit-tested with fake storage. Stores only {roundId, fair} against the room's
 * venue: no IP, no device id, nothing that identifies the guest.
 */
import { z } from 'zod';
import type { SlidingWindowLimiter } from '../rate-limit';
import { FEEDBACK_MAX_BYTES } from './feedback';

export const FairnessFeedbackSchema = z.strictObject({
  roomId: z.uuid(),
  roundId: z.uuid(),
  fair: z.boolean(),
});

/** Votes are only taken for rounds that started this recently (the prompt shows right after a round). */
export const FEEDBACK_ROUND_MAX_AGE_MS = 60 * 60_000;

export interface FeedbackDeps {
  now: number;
  /** Rate-limit key for the caller (a salted IP hash; never stored). */
  clientKey: string;
  perClient: SlidingWindowLimiter;
  /** Process-wide ceiling, counted only for votes that would be stored. */
  global: SlidingWindowLimiter;
  /** venueId of the ACTIVE room `roomId`, provided `roundId` was played there since `since`; else null. */
  findVenue: (roomId: string, roundId: string, since: number) => Promise<string | null>;
  record: (vote: { venueId: string; roundId: string; fair: boolean }) => Promise<void>;
}

function empty(status: number, headers: Record<string, string> = {}): Response {
  return new Response(null, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
}

function tooMany(retryAfterMs: number): Response {
  return empty(429, { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterMs / 1000))) });
}

/** Reads at most `max` bytes; null when the body is larger (the stream is cancelled, not drained). */
async function readCapped(request: Request, max: number): Promise<string | null> {
  if (!request.body) return '';
  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > max) {
      await reader.cancel().catch(() => undefined);
      return null;
    }
    chunks.push(value);
  }
  const bytes = new Uint8Array(size);
  let offset = 0;
  for (const c of chunks) {
    bytes.set(c, offset);
    offset += c.byteLength;
  }
  return new TextDecoder().decode(bytes);
}

export async function handleFeedback(request: Request, deps: FeedbackDeps): Promise<Response> {
  const headers = request.headers;
  // Browsers mark cross-site requests; votes only come from our own play page.
  const fetchSite = headers.get('sec-fetch-site');
  if (fetchSite && fetchSite !== 'same-origin') return empty(403);

  // Size is checked before a single byte is read; a body without a declared length is refused.
  const declared = headers.get('content-length');
  if (declared === null || !/^\d+$/.test(declared)) return empty(411);
  if (Number(declared) > FEEDBACK_MAX_BYTES) return empty(413);
  if (!(headers.get('content-type') ?? '').toLowerCase().includes('application/json')) return empty(415);

  const perClient = deps.perClient.hit(deps.clientKey, deps.now);
  if (!perClient.allowed) return tooMany(perClient.retryAfterMs);

  const text = await readCapped(request, FEEDBACK_MAX_BYTES);
  if (text === null) return empty(413);
  let json: unknown;
  try {
    json = JSON.parse(text);
  } catch {
    return empty(400);
  }
  const parsed = FairnessFeedbackSchema.safeParse(json);
  if (!parsed.success) return empty(400);
  const { roomId, roundId, fair } = parsed.data;

  try {
    const venueId = await deps.findVenue(roomId, roundId, deps.now - FEEDBACK_ROUND_MAX_AGE_MS);
    if (!venueId) return empty(404);
    const global = deps.global.hit('all', deps.now);
    if (!global.allowed) return tooMany(global.retryAfterMs);
    await deps.record({ venueId, roundId, fair });
  } catch (error) {
    console.error('[feedback] Could not record a fairness vote:', error instanceof Error ? error.message : error);
    return empty(503);
  }
  return empty(204);
}
