#!/usr/bin/env node
/**
 * Build client/icon.ico from client/icon.png with proper format coverage:
 *   - 16, 32, 48, 64, 128 px entries: BMP-DIB (BI_RGB) — required by the
 *     Windows Shell taskbar/AUMID code path. PNG-in-ICO at small sizes is
 *     silently rejected, leaving the taskbar showing the generic "document"
 *     icon (the bug behind "icon shows as a piece of paper").
 *   - 256 px entry: PNG (Vista+ accepts it; smaller file than the 256 KB
 *     a 256x256 BMP DIB would be).
 *
 * Run: node bin/build-icon-ico.js
 *
 * Source: client/icon.png. Re-run whenever the source PNG changes.
 *
 * Hand-rolled — no image deps available in the workspace. Decodes 8-bit
 * RGBA / RGB non-interlaced PNG (which is what every standard tool emits),
 * downsamples by box-averaging, encodes BMP DIBs with the trailing AND mask
 * the ICO format requires.
 */
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";

const root = path.resolve(import.meta.dirname, "..");
const src = path.join(root, "client", "icon.png");
const dst = path.join(root, "client", "icon.ico");

if (!fs.existsSync(src)) {
    console.error(`build-icon-ico: source not found: ${src}`);
    process.exit(1);
}

const pngBuf = fs.readFileSync(src);

// ── PNG decoder (8-bit RGBA / RGB, no interlace) ─────────────────────────
function decodePNG(buf) {
    const sigExpected = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
    if (buf.length < 8 || sigExpected.some((b, i) => buf[i] !== b)) {
        throw new Error("not a PNG (bad signature)");
    }
    let pos = 8;
    let width = 0, height = 0, bitDepth = 0, colorType = 0;
    const idat = [];
    while (pos < buf.length) {
        const len = buf.readUInt32BE(pos); pos += 4;
        const type = buf.toString("ascii", pos, pos + 4); pos += 4;
        const data = buf.subarray(pos, pos + len); pos += len + 4; // skip CRC
        if (type === "IHDR") {
            width = data.readUInt32BE(0);
            height = data.readUInt32BE(4);
            bitDepth = data.readUInt8(8);
            colorType = data.readUInt8(9);
            if (data.readUInt8(12) !== 0) {
                throw new Error("interlaced PNG not supported");
            }
        } else if (type === "IDAT") {
            idat.push(data);
        } else if (type === "IEND") {
            break;
        }
    }
    if (bitDepth !== 8 && bitDepth !== 16) {
        throw new Error(`bit depth ${bitDepth} not supported (need 8 or 16)`);
    }
    if (colorType !== 6 && colorType !== 2) {
        throw new Error(`color type ${colorType} not supported (need 2=RGB or 6=RGBA)`);
    }
    const samplesPerPixel = colorType === 6 ? 4 : 3;
    const bytesPerSample = bitDepth / 8;       // 1 or 2
    const bpp = samplesPerPixel * bytesPerSample;
    const inflated = zlib.inflateSync(Buffer.concat(idat));
    const stride = width * bpp;
    const rgba = Buffer.alloc(width * height * 4);
    let inPos = 0;
    let prev = Buffer.alloc(stride);
    let cur = Buffer.alloc(stride);
    for (let y = 0; y < height; y++) {
        const filter = inflated.readUInt8(inPos); inPos += 1;
        inflated.copy(cur, 0, inPos, inPos + stride); inPos += stride;
        switch (filter) {
            case 0: break; // None
            case 1: // Sub
                for (let x = bpp; x < stride; x++) cur[x] = (cur[x] + cur[x - bpp]) & 0xFF;
                break;
            case 2: // Up
                for (let x = 0; x < stride; x++) cur[x] = (cur[x] + prev[x]) & 0xFF;
                break;
            case 3: // Avg
                for (let x = 0; x < stride; x++) {
                    const left = x >= bpp ? cur[x - bpp] : 0;
                    cur[x] = (cur[x] + ((left + prev[x]) >> 1)) & 0xFF;
                }
                break;
            case 4: { // Paeth
                for (let x = 0; x < stride; x++) {
                    const a = x >= bpp ? cur[x - bpp] : 0;
                    const b = prev[x];
                    const c = x >= bpp ? prev[x - bpp] : 0;
                    const p = a + b - c;
                    const pa = Math.abs(p - a);
                    const pb = Math.abs(p - b);
                    const pc = Math.abs(p - c);
                    const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
                    cur[x] = (cur[x] + pred) & 0xFF;
                }
                break;
            }
            default: throw new Error(`unknown PNG filter ${filter}`);
        }
        // 16-bit samples are big-endian; we keep only the high byte (eyeball-
        // identical to dividing by 257 for icon use).
        for (let x = 0; x < width; x++) {
            const di = (y * width + x) * 4;
            const si = x * bpp;
            if (bitDepth === 8) {
                rgba[di + 0] = cur[si + 0];
                rgba[di + 1] = cur[si + 1];
                rgba[di + 2] = cur[si + 2];
                rgba[di + 3] = samplesPerPixel === 4 ? cur[si + 3] : 255;
            } else {
                rgba[di + 0] = cur[si + 0];      // hi byte of R
                rgba[di + 1] = cur[si + 2];      // hi byte of G
                rgba[di + 2] = cur[si + 4];      // hi byte of B
                rgba[di + 3] = samplesPerPixel === 4 ? cur[si + 6] : 255;
            }
        }
        const swap = prev; prev = cur; cur = swap;
    }
    return { width, height, rgba };
}

// ── Box-filter downsample (good enough for icon sizes) ───────────────────
function downsample(src, srcW, srcH, dstW, dstH) {
    if (srcW === dstW && srcH === dstH) return Buffer.from(src);
    const dst = Buffer.alloc(dstW * dstH * 4);
    const sx = srcW / dstW;
    const sy = srcH / dstH;
    for (let y = 0; y < dstH; y++) {
        const y0 = Math.floor(y * sy);
        const y1 = Math.min(srcH, Math.ceil((y + 1) * sy));
        for (let x = 0; x < dstW; x++) {
            const x0 = Math.floor(x * sx);
            const x1 = Math.min(srcW, Math.ceil((x + 1) * sx));
            let r = 0, g = 0, b = 0, a = 0, n = 0;
            for (let yy = y0; yy < y1; yy++) {
                for (let xx = x0; xx < x1; xx++) {
                    const i = (yy * srcW + xx) * 4;
                    r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3];
                    n++;
                }
            }
            const di = (y * dstW + x) * 4;
            dst[di] = (r / n) | 0;
            dst[di + 1] = (g / n) | 0;
            dst[di + 2] = (b / n) | 0;
            dst[di + 3] = (a / n) | 0;
        }
    }
    return dst;
}

// ── BMP DIB encoder for ICO entries (32bpp BGRA + AND mask) ──────────────
function encodeDIB(rgba, w, h) {
    const colorBytes = w * h * 4;
    const maskRowSize = ((w + 31) >> 5) << 2; // 1bpp, 4-byte aligned
    const maskBytes = maskRowSize * h;
    const out = Buffer.alloc(40 + colorBytes + maskBytes);
    // BITMAPINFOHEADER
    out.writeUInt32LE(40, 0);     // biSize
    out.writeInt32LE(w, 4);       // biWidth
    out.writeInt32LE(h * 2, 8);   // biHeight = 2*h (color + mask)
    out.writeUInt16LE(1, 12);     // biPlanes
    out.writeUInt16LE(32, 14);    // biBitCount
    out.writeUInt32LE(0, 16);     // biCompression (BI_RGB)
    // remaining BITMAPINFOHEADER fields stay zero
    // Color: 32bpp BGRA, bottom-up
    let off = 40;
    for (let y = h - 1; y >= 0; y--) {
        for (let x = 0; x < w; x++) {
            const i = (y * w + x) * 4;
            out[off++] = rgba[i + 2]; // B
            out[off++] = rgba[i + 1]; // G
            out[off++] = rgba[i + 0]; // R
            out[off++] = rgba[i + 3]; // A
        }
    }
    // AND mask stays all-zero — alpha channel handles transparency. Required
    // by ICO format though, so we allocate the bytes (already zeroed).
    return out;
}

// ── ICO container ────────────────────────────────────────────────────────
function buildICO(entries) {
    const dirSize = 6 + 16 * entries.length;
    const dir = Buffer.alloc(dirSize);
    dir.writeUInt16LE(0, 0);                  // reserved
    dir.writeUInt16LE(1, 2);                  // type = icon
    dir.writeUInt16LE(entries.length, 4);     // count
    let dataOff = dirSize;
    for (let i = 0; i < entries.length; i++) {
        const e = entries[i];
        const o = 6 + i * 16;
        dir.writeUInt8(e.size === 256 ? 0 : e.size, o);
        dir.writeUInt8(e.size === 256 ? 0 : e.size, o + 1);
        dir.writeUInt8(0, o + 2);              // ncolors
        dir.writeUInt8(0, o + 3);              // reserved
        dir.writeUInt16LE(1, o + 4);           // planes
        dir.writeUInt16LE(32, o + 6);          // bpp
        dir.writeUInt32LE(e.data.length, o + 8);
        dir.writeUInt32LE(dataOff, o + 12);
        dataOff += e.data.length;
    }
    return Buffer.concat([dir, ...entries.map(e => e.data)]);
}

// ── Main ─────────────────────────────────────────────────────────────────
const decoded = decodePNG(pngBuf);
console.log(`build-icon-ico: source ${decoded.width}x${decoded.height} ${decoded.rgba.length} bytes RGBA`);

const sizes = [16, 32, 48, 64, 128, 256];
const entries = [];
for (const sz of sizes) {
    const scaled = downsample(decoded.rgba, decoded.width, decoded.height, sz, sz);
    if (sz === 256) {
        // 256x256 BMP would be 256KB — store as PNG instead. Vista+ Shell
        // accepts PNG entries at >=96px; <96px still has to be BMP for the
        // taskbar code path on older configs.
        const pngEntry = encodePNGEntry(scaled, sz);
        entries.push({ size: sz, data: pngEntry });
    } else {
        entries.push({ size: sz, data: encodeDIB(scaled, sz, sz) });
    }
}

// Re-encode an RGBA buffer back to a PNG (used only for the 256x256 entry).
function encodePNGEntry(rgba, sz) {
    function chunk(type, data) {
        const len = Buffer.alloc(4);
        len.writeUInt32BE(data.length, 0);
        const head = Buffer.concat([Buffer.from(type, "ascii"), data]);
        const crc = Buffer.alloc(4);
        crc.writeInt32BE(crc32(head), 0);
        return Buffer.concat([len, head, crc]);
    }
    const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
    const ihdr = Buffer.alloc(13);
    ihdr.writeUInt32BE(sz, 0);
    ihdr.writeUInt32BE(sz, 4);
    ihdr[8] = 8;     // bit depth
    ihdr[9] = 6;     // RGBA
    ihdr[10] = 0;    // compression
    ihdr[11] = 0;    // filter
    ihdr[12] = 0;    // interlace
    // Filter byte 0 (None) per scanline + RGBA pixels
    const stride = sz * 4;
    const filtered = Buffer.alloc(sz * (stride + 1));
    for (let y = 0; y < sz; y++) {
        filtered[y * (stride + 1)] = 0;
        rgba.copy(filtered, y * (stride + 1) + 1, y * stride, y * stride + stride);
    }
    const idat = zlib.deflateSync(filtered, { level: 9 });
    return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
}

// Standard PNG-spec CRC32.
function crc32(buf) {
    let c, table = crc32.table;
    if (!table) {
        table = crc32.table = new Int32Array(256);
        for (let n = 0; n < 256; n++) {
            c = n;
            for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
            table[n] = c;
        }
    }
    c = -1;
    for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
    return c ^ -1;
}

const ico = buildICO(entries);
fs.writeFileSync(dst, ico);
console.log(`build-icon-ico: wrote ${dst} (${ico.length} bytes, sizes: ${sizes.join(", ")})`);
