// The QR transfer wire format.
//
// TEMPORARY / EXPERIMENTAL. Inspired by deedy/qr-data-transfer (QRFerry), which carries RFC 6330
// RaptorQ fountain-coded symbols so a receiver can join mid-stream and rebuild from *any* sufficient
// set of frames. This is deliberately NOT that.
//
// WHY NOT RAPTORQ. Two reasons, and the second is the real one:
//   1. RFC 6330 is days of work to implement correctly and is unpleasant to debug.
//   2. The sender and receiver are being reimplemented on iOS and Android. A format one person can
//      re-derive from this file in an afternoon is worth far more here than optical efficiency. Every
//      frame is independent, self-describing and parseable with a string split.
//
// WHAT WE GIVE UP. Without fountain coding you must eventually capture each specific frame, so a missed
// frame waits for the next pass of the carousel rather than being covered by any surplus symbol. The
// sender loops forever; the receiver fills its gaps over successive passes. For a few hundred KB over a
// steady camera this converges fine. It degrades badly for large files on a shaky camera, which is the
// point at which RaptorQ starts to earn its complexity.
//
// ── FRAME FORMAT ────────────────────────────────────────────────────────────────────────────────────
// Text, QR byte mode, ':' separated. Two kinds, distinguished by the second field:
//
//   META:  QRF1:M:<sid>:<total>:<size>:<crc32>:<b64url(name)>:<mime>
//   DATA:  QRF1:D:<sid>:<seq>:<b64url(chunk)>
//
//   sid    8 hex chars, identifies one transfer so a receiver ignores a stream it is not following
//   total  number of DATA frames
//   size   original byte length, for progress and a final sanity check
//   crc32  CRC-32 of the WHOLE original file, hex — the end-to-end integrity check
//   seq    0-based frame index
//
// Base64URL (no padding) because ':' and '/' would collide with the separator and '+' is awkward in
// some QR decoders. The meta frame is re-emitted periodically so a receiver that joins late gets it.
// ─────────────────────────────────────────────────────────────────────────────────────────────────────

export const PROTOCOL = 'QRF1';

/**
 * Raw bytes per DATA frame, before base64. 600 → ~800 base64 chars, comfortably inside a QR that a
 * phone camera resolves at arm's length. Larger frames mean fewer passes but a denser code that a
 * mediocre camera starts to miss, which costs more than it saves.
 */
export const DEFAULT_CHUNK_BYTES = 600;

export type MetaFrame = {
  kind: 'meta';
  sid: string;
  total: number;
  size: number;
  crc32: string;
  name: string;
  mime: string;
};

export type DataFrame = { kind: 'data'; sid: string; seq: number; bytes: Uint8Array };

export type Frame = MetaFrame | DataFrame;

// ── base64url, byte-exact in both directions ──

const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';

export function toB64url(bytes: Uint8Array): string {
  let out = '';
  for (let i = 0; i < bytes.length; i += 3) {
    const a = bytes[i]!;
    const b = bytes[i + 1];
    const c = bytes[i + 2];
    out += B64_CHARS[a >> 2];
    out += B64_CHARS[((a & 3) << 4) | ((b ?? 0) >> 4)];
    if (b === undefined) break;
    out += B64_CHARS[((b & 15) << 2) | ((c ?? 0) >> 6)];
    if (c === undefined) break;
    out += B64_CHARS[c & 63];
  }
  return out;
}

export function fromB64url(s: string): Uint8Array {
  const out: number[] = [];
  let buffer = 0;
  let bits = 0;
  for (const ch of s) {
    const v = B64_CHARS.indexOf(ch);
    if (v < 0) continue;
    buffer = (buffer << 6) | v;
    bits += 6;
    if (bits >= 8) {
      bits -= 8;
      out.push((buffer >> bits) & 0xff);
    }
  }
  return new Uint8Array(out);
}

// ── CRC-32, the same polynomial everyone else uses, so the phone side can use any stock implementation ──

const CRC_TABLE = (() => {
  const t = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    t[i] = c >>> 0;
  }
  return t;
})();

export function crc32(bytes: Uint8Array): string {
  let c = 0xffffffff;
  for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
  return ((c ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
}

// ── encode ──

export function encodeMeta(m: Omit<MetaFrame, 'kind'>): string {
  const name = toB64url(new TextEncoder().encode(m.name));
  return [PROTOCOL, 'M', m.sid, m.total, m.size, m.crc32, name, m.mime || 'application/octet-stream'].join(':');
}

export function encodeData(sid: string, seq: number, bytes: Uint8Array): string {
  return [PROTOCOL, 'D', sid, seq, toB64url(bytes)].join(':');
}

// ── decode ──

/** Returns null for anything that is not one of our frames — the camera sees plenty of other QR codes. */
export function decodeFrame(text: string): Frame | null {
  if (!text.startsWith(`${PROTOCOL}:`)) return null;
  const parts = text.split(':');

  if (parts[1] === 'M' && parts.length >= 8) {
    const [, , sid, total, size, crc, name, ...mime] = parts;
    return {
      kind: 'meta',
      sid: sid!,
      total: Number(total),
      size: Number(size),
      crc32: crc!,
      name: new TextDecoder().decode(fromB64url(name!)),
      // mime can itself contain ':' in exotic cases; rejoin whatever is left.
      mime: mime.join(':') || 'application/octet-stream',
    };
  }

  if (parts[1] === 'D' && parts.length >= 5) {
    return { kind: 'data', sid: parts[2]!, seq: Number(parts[3]), bytes: fromB64url(parts[4]!) };
  }

  return null;
}

// ── helpers shared by both sides ──

export function chunkFile(bytes: Uint8Array, chunkBytes = DEFAULT_CHUNK_BYTES): Uint8Array[] {
  const chunks: Uint8Array[] = [];
  for (let i = 0; i < bytes.length; i += chunkBytes) chunks.push(bytes.subarray(i, i + chunkBytes));
  return chunks;
}

export function newSessionId(): string {
  return Array.from(crypto.getRandomValues(new Uint8Array(4)))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

/** Reassemble once every index is present. Returns null while any are still missing. */
export function reassemble(chunks: Map<number, Uint8Array>, total: number): Uint8Array | null {
  if (chunks.size !== total) return null;
  let length = 0;
  for (let i = 0; i < total; i++) {
    const c = chunks.get(i);
    if (!c) return null;
    length += c.length;
  }
  const out = new Uint8Array(length);
  let offset = 0;
  for (let i = 0; i < total; i++) {
    out.set(chunks.get(i)!, offset);
    offset += chunks.get(i)!.length;
  }
  return out;
}
