import { BITMAP_FONT } from "./bitmap.ts";

export interface RgbaImage {
  width: number;
  height: number;
  data: Uint8Array;
}

export type Rgba = readonly [number, number, number, number?];

export function createImage(
  width: number,
  height: number,
  background: Rgba = [255, 255, 255, 255],
): RgbaImage {
  const data = new Uint8Array(width * height * 4);
  const alpha = background[3] ?? 255;
  for (let index = 0; index < data.length; index += 4) {
    data[index] = background[0];
    data[index + 1] = background[1];
    data[index + 2] = background[2];
    data[index + 3] = alpha;
  }
  return { width, height, data };
}

export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): void {
  const px = Math.round(x);
  const py = Math.round(y);
  if (px < 0 || py < 0 || px >= image.width || py >= image.height) return;
  const index = (py * image.width + px) * 4;
  const alpha = (color[3] ?? 255) / 255;
  const inverse = 1 - alpha;
  image.data[index] = Math.round(color[0] * alpha + image.data[index] * inverse);
  image.data[index + 1] = Math.round(color[1] * alpha + image.data[index + 1] * inverse);
  image.data[index + 2] = Math.round(color[2] * alpha + image.data[index + 2] * inverse);
  image.data[index + 3] = 255;
}

export function fillRect(
  image: RgbaImage,
  x: number,
  y: number,
  width: number,
  height: number,
  color: Rgba,
): void {
  for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
    for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
  }
}

export function drawLine(
  image: RgbaImage,
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  color: Rgba,
  thickness = 1,
): void {
  let x = Math.round(x0);
  let y = Math.round(y0);
  const targetX = Math.round(x1);
  const targetY = Math.round(y1);
  const dx = Math.abs(targetX - x);
  const dy = -Math.abs(targetY - y);
  const sx = x < targetX ? 1 : -1;
  const sy = y < targetY ? 1 : -1;
  let error = dx + dy;
  while (true) {
    fillRect(
      image,
      x - Math.floor(thickness / 2),
      y - Math.floor(thickness / 2),
      thickness,
      thickness,
      color,
    );
    if (x === targetX && y === targetY) break;
    const twice = 2 * error;
    if (twice >= dy) {
      error += dy;
      x += sx;
    }
    if (twice <= dx) {
      error += dx;
      y += sy;
    }
  }
}

export function fillCircle(
  image: RgbaImage,
  centerX: number,
  centerY: number,
  radius: number,
  color: Rgba,
): void {
  const r2 = radius * radius;
  for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
    for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
      const dx = x - centerX;
      const dy = y - centerY;
      if (dx * dx + dy * dy <= r2) setPixel(image, x, y, color);
    }
  }
}

function pointInPolygon(
  x: number,
  y: number,
  points: readonly (readonly [number, number])[],
): boolean {
  let inside = false;
  for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
    const xi = points[i]![0];
    const yi = points[i]![1];
    const xj = points[j]![0];
    const yj = points[j]![1];
    const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1) + xi;
    if (intersects) inside = !inside;
  }
  return inside;
}

export function fillPolygon(
  image: RgbaImage,
  points: readonly (readonly [number, number])[],
  color: Rgba,
): void {
  const minX = Math.floor(Math.min(...points.map((point) => point[0])));
  const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
  const minY = Math.floor(Math.min(...points.map((point) => point[1])));
  const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
  for (let y = minY; y <= maxY; y++) {
    for (let x = minX; x <= maxX; x++)
      if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
  }
}

export function drawGlyph(
  image: RgbaImage,
  character: string,
  x: number,
  y: number,
  scale: number,
  color: Rgba,
  shear = 0,
): void {
  const glyph = BITMAP_FONT[character.toUpperCase()] ?? BITMAP_FONT["?"]!;
  for (let row = 0; row < glyph.length; row++) {
    const line = glyph[row]!;
    for (let column = 0; column < line.length; column++) {
      if (line[column] !== "1") continue;
      const offset = Math.round((glyph.length - row) * shear);
      fillRect(image, x + column * scale + offset, y + row * scale, scale, scale, color);
    }
  }
}

export function drawText(
  image: RgbaImage,
  text: string,
  options: {
    x: number;
    y: number;
    scale: number;
    color: Rgba;
    spacing?: number;
    jitter?: (index: number) => { x: number; y: number; shear: number };
  },
): void {
  const spacing = options.spacing ?? options.scale * 2;
  let cursor = options.x;
  for (let index = 0; index < text.length; index++) {
    const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
    drawGlyph(
      image,
      text[index]!,
      cursor + jitter.x,
      options.y + jitter.y,
      options.scale,
      options.color,
      jitter.shear,
    );
    cursor += options.scale * 5 + spacing;
  }
}

const CRC32_TABLE = (() => {
  const table = new Uint32Array(256);
  for (let index = 0; index < table.length; index++) {
    let value = index;
    for (let bit = 0; bit < 8; bit++) {
      value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
    }
    table[index] = value >>> 0;
  }
  return table;
})();

function crc32(bytes: Uint8Array): number {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 255]!;
  }
  return (crc ^ 0xffffffff) >>> 0;
}

function adler32(bytes: Uint8Array): number {
  let a = 1;
  let b = 0;
  for (const byte of bytes) {
    a = (a + byte) % 65521;
    b = (b + a) % 65521;
  }
  return ((b << 16) | a) >>> 0;
}

function u32(value: number): Uint8Array {
  return Uint8Array.of(
    (value >>> 24) & 255,
    (value >>> 16) & 255,
    (value >>> 8) & 255,
    value & 255,
  );
}

function concat(parts: readonly Uint8Array[]): Uint8Array {
  const length = parts.reduce((sum, part) => sum + part.length, 0);
  const out = new Uint8Array(length);
  let offset = 0;
  for (const part of parts) {
    out.set(part, offset);
    offset += part.length;
  }
  return out;
}

function chunk(type: string, data: Uint8Array): Uint8Array {
  const typeBytes = new TextEncoder().encode(type);
  return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]);
}

function deflateStored(data: Uint8Array): Uint8Array {
  const blocks: Uint8Array[] = [Uint8Array.of(0x78, 0x01)];
  for (let offset = 0; offset < data.length; offset += 65535) {
    const size = Math.min(65535, data.length - offset);
    const final = offset + size >= data.length;
    const length = size;
    const inverse = ~length & 0xffff;
    blocks.push(
      Uint8Array.of(
        final ? 1 : 0,
        length & 255,
        (length >>> 8) & 255,
        inverse & 255,
        (inverse >>> 8) & 255,
      ),
      data.slice(offset, offset + size),
    );
  }
  blocks.push(u32(adler32(data)));
  return concat(blocks);
}

export function encodePng(image: RgbaImage): Uint8Array {
  const stride = image.width * 4;
  const scanlines = new Uint8Array((stride + 1) * image.height);
  for (let y = 0; y < image.height; y++) {
    const target = y * (stride + 1);
    scanlines[target] = 0;
    scanlines.set(image.data.slice(y * stride, (y + 1) * stride), target + 1);
  }
  const header = new Uint8Array(13);
  header.set(u32(image.width), 0);
  header.set(u32(image.height), 4);
  header[8] = 8;
  header[9] = 6;
  return concat([
    Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10),
    chunk("IHDR", header),
    chunk("IDAT", deflateStored(scanlines)),
    chunk("IEND", new Uint8Array()),
  ]);
}

export function bytesToBase64(bytes: Uint8Array): string {
  let binary = "";
  const size = 0x8000;
  for (let offset = 0; offset < bytes.length; offset += size) {
    binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.length, offset + size)));
  }
  return btoa(binary);
}

export function pngDataUri(image: RgbaImage): string {
  return `data:image/png;base64,${bytesToBase64(encodePng(image))}`;
}
