#!/usr/bin/env node
/**
 * batch_render.js
 * 20260228 ChatGPT
 * $Header$
 *
 * This script takes a JSON job description and batch renders PNGs from GLBs 
 * using a headless browser and a local static server.
 * 
 * Example:
 *   node batch_render.js job.json
 */

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import http from 'node:http';
import { pathToFileURL, fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import sharp from 'sharp';

function die (msg) { console.error(msg); process.exit(1); }
function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); }


function listFiles(dir, suffix) {
  const out = [];
  for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, ent.name);
    if (ent.isDirectory()) out.push(...listFiles(full, suffix));
    else if (!suffix || ent.name.toLowerCase().endsWith(suffix)) out.push(full);
  }
  return out;
}

function sanitizeBasename(p) {
  // While sanitizing is generally a good idea, it causes the output to deviate
  // from the input, which makes it harder to verify that the right files were
  // processed. For now, just use the original filename making this step a nullity.

  const b = path.basename(p);
  //return b.replace(/[^\w.\-]+/g, '_');
  return b;  // No sanitization, just the original path
}

function mimeTypeFor(p) {
  const ext = path.extname(p).toLowerCase();
  if (ext === '.html') return 'text/html; charset=utf-8';
  if (ext === '.js')   return 'text/javascript; charset=utf-8';
  if (ext === '.json') return 'application/json; charset=utf-8';
  if (ext === '.glb')  return 'model/gltf-binary';
  if (ext === '.gltf') return 'model/gltf+json; charset=utf-8';
  if (ext === '.bin')  return 'application/octet-stream';
  if (ext === '.png')  return 'image/png';
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
  if (ext === '.css')  return 'text/css; charset=utf-8';
  return 'application/octet-stream';
}

// Serve static files from rootDir
function startStaticServer(rootDir) {
  const server = http.createServer((req, res) => {
    try {
      const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
      const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
      const filePath = path.join(rootDir, safePath);

      // Default route
      const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()
        ? path.join(filePath, 'index.html')
        : filePath;

      if (!fs.existsSync(finalPath) || !fs.statSync(finalPath).isFile()) {
        res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
        res.end('404\n');
        return;
      }

      const data = fs.readFileSync(finalPath);
      res.writeHead(200, { 'Content-Type': mimeTypeFor(finalPath) });
      res.end(data);
    } catch (e) {
      res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
      res.end(`500\n${String(e)}\n`);
    }
  });

  return new Promise((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      const addr = server.address();
      resolve({ server, port: addr.port });
    });
  });
}

async function main() {
  const jobPath = process.argv[2];
  if (!jobPath) die("Usage: node batch_render.js job.json");

  const jobAbs = path.resolve(jobPath);
  const jobDir = path.dirname(jobAbs);
  const job = JSON.parse(fs.readFileSync(jobAbs, 'utf8'));

  const profileAbs = path.resolve(jobDir, job.profile ?? die("job.profile missing"));
  const inputDirAbs = path.resolve(jobDir, job.input_dir ?? die("job.input_dir missing"));
  const outputDirAbs = path.resolve(jobDir, job.output_dir ?? die("job.output_dir missing"));
  const pattern = (job.pattern ?? ".glb").toLowerCase();

  ensureDir(outputDirAbs);
  const webpDirAbs = path.join(outputDirAbs, '../webp');
  ensureDir(webpDirAbs);

  const glbs = listFiles(inputDirAbs, pattern);
  if (glbs.length === 0) die(`No files ending with '${pattern}' found under ${inputDirAbs}`);

  const renderHtmlAbs = path.resolve(jobDir, 'render.html');
  if (!fs.existsSync(renderHtmlAbs)) die(`Missing render.html at ${renderHtmlAbs}`);

  // Start local server rooted at jobDir so render.html, libs/, glbs/, profile json are reachable
  const { server, port } = await startStaticServer(jobDir);
  const base = `http://127.0.0.1:${port}`;

  // Load profile for viewport sizing
  const profile = JSON.parse(fs.readFileSync(profileAbs, 'utf8'));
  const vw = profile.output?.width ?? 1024;
  const vh = profile.output?.height ?? 768;
  const dpr = profile.output?.pixelRatio ?? 1;

    const browser = await puppeteer.launch({
	headless: 'new',
	args: [
	    '--no-sandbox',
	    '--disable-dev-shm-usage',
	    '--disable-gpu',
	    '--use-gl=swiftshader',
	    '--enable-unsafe-swiftshader'
	]
    });


  try {
    const page = await browser.newPage();

    // Log page-side failures clearly
    page.on('console', (msg) => console.log('PAGE console:', msg.type(), msg.text()));
    page.on('pageerror', (err) => console.log('PAGE error:', err));
    page.on('requestfailed', (req) => console.log('REQ failed:', req.url(), req.failure()?.errorText));

    await page.setViewport({ width: vw, height: vh, deviceScaleFactor: dpr });

    // Make URLs relative to server root
    const renderUrl = `${base}/render.html`;
    const profileUrl = `${base}/${encodeURIComponent(path.relative(jobDir, profileAbs)).replace(/%2F/g,'/')}`;

    for (const glbAbs of glbs) {
      const rel = path.relative(jobDir, glbAbs);
      const glbUrl = `${base}/${encodeURIComponent(rel).replace(/%2F/g,'/')}`;

      const outName = sanitizeBasename(glbAbs).replace(/\.glb$/i, '.png');
      const outAbs = path.join(outputDirAbs, outName);

      const url = `${renderUrl}?glb=${encodeURIComponent(glbUrl)}&profile=${encodeURIComponent(profileUrl)}`;

      // Don’t use networkidle0 here; module imports + GLB load can keep network “busy”
      await page.goto(url, { waitUntil: 'load' });

      await page.waitForFunction(() => {
        return window.__RENDER_STATUS__ && window.__RENDER_STATUS__.ready === true;
      }, { timeout: 120000 }); // 2 minutes, GLBs can be chunky

      const status = await page.evaluate(() => window.__RENDER_STATUS__);
      if (!status || !status.pngDataUrl) {
        throw new Error(`No pngDataUrl produced for ${glbAbs} (status=${JSON.stringify(status)})`);
      }

      //const b64 = status.pngDataUrl.replace(/^data:image\/png;base64,/, '');
      //fs.writeFileSync(outAbs, Buffer.from(b64, 'base64'));
      //console.log(`WROTE\t${outAbs}`);
      const b64 = status.pngDataUrl.replace(/^data:image\/png;base64,/, '');
      const pngBuffer = Buffer.from(b64, 'base64');

      /* write PNG */
      fs.writeFileSync(outAbs, pngBuffer);
      console.log(`WROTE\t${outAbs}`);

      /* write WEBP */
      const webpName = sanitizeBasename(glbAbs).replace(/\.glb$/i, '.webp');
      const webpAbs = path.join(webpDirAbs, webpName);

      await sharp(pngBuffer)
        .webp({ quality: 92 })
        .toFile(webpAbs);

      console.log(`WROTE\t${webpAbs}`);
    }

    await page.close();
  } finally {
    await browser.close();
    server.close();
  }
}

main().catch((e) => { console.error("ERROR:", e); process.exit(2); });
