import type { ConsoleErrorEntry } from './console-buffer';

/**
 * Assembles the JSON body for an in-builder issue report.
 *
 * Pure: every environment value (user agent, viewport, serialized canvas)
 * is passed IN rather than read from globals, so the whole thing is testable
 * without a DOM and the caller decides what it is willing to send.
 *
 * The canvas state is the only field that can be large. When it would push
 * the body over the cap it is DROPPED WHOLE and flagged -- a truncated craft
 * state is not merely useless, it is misleading (it looks like a valid tree
 * that lost nodes).
 */

export const MAX_PAYLOAD_BYTES = 512 * 1024;

/**
 * Matches the limit the server-side validator enforces (a later task) so the
 * client never builds a body the server would reject outright. Truncated
 * silently, not flagged: unlike canvas_state (a serialized tree, where a cut
 * mid-structure looks like a smaller-but-still-valid tree and actively
 * misleads whoever reads it), a truncated free-text description is exactly
 * what it looks like -- text that stops partway through. Nothing about the
 * cut invents false structure, and the limit mirrors what the server would
 * have discarded anyway.
 */
export const MAX_DESCRIPTION_CHARS = 5000;

export type ReportCategory = 'bug' | 'confusing' | 'feature';

export interface BuildReportPayloadInput {
  category: ReportCategory;
  description: string;
  includeCanvas: boolean;
  siteId: number | null;
  siteDomain: string;
  pageId: string;
  pageSlug: string;
  editorVersion: string;
  userAgent: string;
  viewport: string;
  deviceMode: string;
  selectedType: string | null;
  consoleErrors: ConsoleErrorEntry[];
  canvasState: string | null;
}

export interface ReportPayload {
  category: ReportCategory;
  description: string;
  site_id: number | null;
  site_domain: string;
  page_id: string;
  page_slug: string;
  editor_version: string;
  user_agent: string;
  viewport: string;
  device_mode: string;
  selected_type: string | null;
  console_errors: ConsoleErrorEntry[];
  canvas_state: string | null;
  /** Present only when the canvas state was dropped. */
  canvas_state_omitted?: 'size' | 'opt-out';
}

function byteLength(value: string): number {
  // TextEncoder is standard in every environment this module actually runs
  // in (Node/vitest and every real browser target). The fallback below is
  // unreachable by design -- kept only so a missing global degrades to a
  // conservative-ish count rather than throwing, not because it's expected
  // to fire. (It undercounts multi-byte UTF-8, so treat it as dead code.)
  if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length;
  return value.length;
}

function payloadBytes(payload: ReportPayload): number {
  return byteLength(JSON.stringify(payload));
}

/**
 * Every return path funnels through here so the cap is checked
 * unconditionally, not just on the canvas-included branch. If the payload
 * is still over budget after every available reduction (dropping
 * canvas_state, truncating description), there is nothing left to cut --
 * returning it anyway would ship an oversized body that, if it carries
 * `canvas_state_omitted: 'size'`, falsely claims the drop fixed things.
 * Throwing surfaces that as a distinct, honest failure instead.
 */
function finalize(payload: ReportPayload): ReportPayload {
  if (payloadBytes(payload) > MAX_PAYLOAD_BYTES) {
    throw new Error(
      `Report payload is ${payloadBytes(payload)} bytes, over the ${MAX_PAYLOAD_BYTES}-byte cap, ` +
        'even with canvas_state dropped. Nothing left to reduce.'
    );
  }
  return payload;
}

export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload {
  const description = input.description.trim().slice(0, MAX_DESCRIPTION_CHARS);

  const base: ReportPayload = {
    category: input.category,
    description,
    site_id: input.siteId,
    site_domain: input.siteDomain,
    page_id: input.pageId,
    page_slug: input.pageSlug,
    editor_version: input.editorVersion,
    user_agent: input.userAgent,
    viewport: input.viewport,
    device_mode: input.deviceMode,
    selected_type: input.selectedType,
    console_errors: input.consoleErrors,
    canvas_state: null,
  };

  if (!input.includeCanvas) {
    // The opt-out promise is inviolable: canvas_state is never populated
    // from input.canvasState on this path, no matter what else changes
    // below it. Only the cap is checked here, not whether to include canvas.
    return finalize({ ...base, canvas_state_omitted: 'opt-out' });
  }

  if (!input.canvasState) return finalize(base);

  const withCanvas: ReportPayload = { ...base, canvas_state: input.canvasState };
  if (payloadBytes(withCanvas) <= MAX_PAYLOAD_BYTES) return withCanvas;

  // canvas_state alone pushed this over budget: drop it whole (never
  // truncate -- a partial Craft tree looks valid but is missing nodes,
  // which is worse than no tree) and re-measure. The 'size' marker is only
  // attached once we've confirmed dropping the canvas actually brought the
  // payload back under the cap; finalize() throws instead of returning it
  // if it didn't.
  return finalize({ ...base, canvas_state: null, canvas_state_omitted: 'size' });
}
