// server.js — HILPOS TT200 LAN printer bridge
// HTTP : 9091  |  HTTPS : 9092
// Pipeline: HTML (RTL/Persian) -> PNG (puppeteer-core) -> 1-bit bitmap -> ESC/POS raster -> TCP to printer:9100

const express = require('express');
const cors = require('cors');
const fs = require('fs');
const net = require('net');
const path = require('path');
const os = require('os');
const https = require('https');
const { PNG } = require('pngjs');
const puppeteer = require('puppeteer-core');
const sharp = require('sharp');
const bmpjs = require('bmp-js');

const escpos = require('@node-escpos/core');
const Network = require('@node-escpos/network-adapter');

const app = express();

/* ------------------------ CORS / PNA ------------------------ */
// Exact origins only (no wildcard) to satisfy Chrome PNA from HTTPS pages
const ALLOWED_ORIGINS = new Set([
    'https://hamekara.com',
    'https://www.hamekara.com',
    // add more if needed:
    // 'https://staging.hamekara.com',
]);

app.use(express.json({ limit: '8mb' }));

app.use((req, res, next) => {
    const origin = req.headers.origin || '';
    if (ALLOWED_ORIGINS.has(origin)) {
        res.setHeader('Access-Control-Allow-Origin', origin);
    }
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
    // Key header that allows HTTPS page -> local network request
    res.setHeader('Access-Control-Allow-Private-Network', 'true');
    if (req.method === 'OPTIONS') return res.sendStatus(204);
    next();
});

app.use(
    cors({
        origin: (origin, cb) => {
            if (!origin) return cb(null, false);
            return cb(null, ALLOWED_ORIGINS.has(origin) ? origin : false);
        },
        methods: ['GET', 'POST', 'OPTIONS'],
        allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
        credentials: false,
    })
);

/* ------------------------ Utils ------------------------ */
const OUT_DIR = path.join(__dirname, 'out');
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR);

function lanIPs() {
    const nets = os.networkInterfaces();
    const out = [];
    for (const name of Object.keys(nets)) {
        for (const n of nets[name] || []) {
            if (n.family === 'IPv4' && !n.internal) out.push(n.address);
        }
    }
    return out;
}

/* ------------------------ Chrome launcher ------------------------ */
let browserPromise = null;

function findChromeExecutable() {
    // Env var wins
    const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
    if (envPath && fs.existsSync(envPath)) return envPath;

    // Common Windows locations; include Edge as fallback
    const candidates = [
        'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
        'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
        path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe'),
        'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
        'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
    ];
    for (const p of candidates) if (p && fs.existsSync(p)) return p;

    throw new Error('No Chromium browser found. Install Chrome/Edge or set PUPPETEER_EXECUTABLE_PATH');
}

async function getBrowser() {
    if (!browserPromise) {
        const exe = findChromeExecutable();
        browserPromise = puppeteer.launch({
            headless: true,
            executablePath: exe,
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-gpu',
                '--disable-dev-shm-usage',
                '--font-render-hinting=medium',
                '--force-color-profile=srgb',
            ],
        });
    }
    return browserPromise;
}

/* ------------------------ HTML -> PNG (BUFFER) ------------------------ */
async function renderHtmlToPngBuffer(html, widthPx = 576) {
    const browser = await getBrowser();
    const page = await browser.newPage();

    await page.setViewport({
        width: Number(widthPx) || 576,
        height: 1000, // will auto-extend via fullPage screenshot
        deviceScaleFactor: 1,
    });

    await page.emulateMediaType('screen');
    await page.setContent(html, { waitUntil: ['domcontentloaded', 'networkidle0'] });

    // Let layout settle a frame or two
    await page.evaluate(
        () =>
            new Promise((r) =>
                requestAnimationFrame(() => requestAnimationFrame(r))
            )
    );

    const buffer = await page.screenshot({
        type: 'png',
        fullPage: true,
        omitBackground: false, // ensure white background
    });

    await page.close();
    return buffer;
}

// Build ESC * (24-dot double-density) bit image from mono map
function buildEscposBitImage_ESCStar({ mono, width, height, cut = true }) {
    const ESC = 0x1B, GS = 0x1D;
    const m = 33; // 24-dot double density
    const bytesPerCol = Math.ceil(width / 8);

    const out = [];
    // Init
    out.push(Buffer.from([ESC, 0x40])); // ESC @

    for (let bandY = 0; bandY < height; bandY += 24) {
        // ESC * m nL nH
        const nL = bytesPerCol & 0xFF;
        const nH = (bytesPerCol >> 8) & 0xFF;
        out.push(Buffer.from([ESC, 0x2A, m, nL, nH]));

        // For each horizontal byte (8 px)
        for (let xb = 0; xb < bytesPerCol; xb++) {
            // For each of 24 rows -> 3 bytes vertical
            for (let k = 0; k < 3; k++) {
                let byte = 0;
                for (let bit = 0; bit < 8; bit++) {
                    const x = xb * 8 + bit;
                    const y = bandY + k * 8 + 0; // base inside this 8-row slice
                    const yy = bandY + k * 8 + bit; // vertical bit (one bit per row)
                    const yyClamped = bandY + k * 8 + bit;
                    const inRange = (x < width) && (yyClamped < height);
                    const ink = inRange ? (mono[yyClamped * width + x] & 1) : 0; // 1=black
                    byte |= (ink << (7 - bit));
                }
                out.push(Buffer.from([byte]));
            }
        }
        // line feed after each 24-dot band
        out.push(Buffer.from([0x0A]));
    }

    // Feed + partial cut
    out.push(Buffer.from([0x1B, 0x64, 0x03])); // ESC d 3
    if (cut) out.push(Buffer.from([GS, 0x56, 0x42, 0x03])); // GS V 66 3
    return Buffer.concat(out);
}

/* ------------------------ IMAGE -> 1-bit + ESC/POS ------------------------ */
// Convert an RGBA/greyscale image buffer to 1bpp bitmap + ESC/POS packed bytes
// Convert any image buffer -> 1bpp mono + packed rows + BMP preview
async function imageToMonochrome({ inputBuffer, widthLimitPx = 576, threshold = 235, dither = true }) {
    const s = sharp(inputBuffer).resize({ width: widthLimitPx, withoutEnlargement: true }).greyscale();
    const { data: gray, info } = await s.raw().toBuffer({ resolveWithObject: true });
    const { width, height } = info;

    const mono = new Uint8Array(width * height); // 1 = black ink, 0 = white
    const err = dither ? new Float32Array(gray) : null;

    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const i = y * width + x;
            const oldP = dither ? err[i] : gray[i];
            const black = oldP < threshold ? 1 : 0;
            mono[i] = black;
            if (dither) {
                const newP = black ? 0 : 255;
                const qe = oldP - newP;
                if (x + 1 < width)            err[i + 1]         += qe * 7 / 16;
                if (y + 1 < height && x > 0)  err[i + width - 1] += qe * 3 / 16;
                if (y + 1 < height)           err[i + width]     += qe * 5 / 16;
                if (y + 1 < height && x + 1 < width) err[i + width + 1] += qe * 1 / 16;
            }
        }
    }

    // Pack mono (horizontal) for GS v 0
    const rowBytes = Math.ceil(width / 8);
    const packed = Buffer.alloc(rowBytes * height);
    let nonwhite = 0;
    for (let y = 0; y < height; y++) {
        for (let xb = 0; xb < rowBytes; xb++) {
            let byte = 0;
            for (let b = 0; b < 8; b++) {
                const x = xb * 8 + b;
                if (x < width) {
                    const bit = mono[y * width + x] & 1; // 1 = black
                    byte |= bit << (7 - b);
                    nonwhite += bit;
                }
            }
            packed[y * rowBytes + xb] = byte;
        }
    }

    // BMP preview (for browser)
    const rgba = Buffer.alloc(width * height * 4);
    for (let i = 0; i < mono.length; i++) {
        const v = mono[i] ? 0 : 255; // black ink -> black pixel
        const o = i * 4;
        rgba[o] = v; rgba[o + 1] = v; rgba[o + 2] = v; rgba[o + 3] = 255;
    }
    const bmp = bmpjs.encode({ width, height, data: rgba });

    return { width, height, rowBytes, packed, mono, approxNonWhite: nonwhite, bmpBuffer: bmp.data };
}

// Build ESC/POS GS v 0 (raster bit image) command block from packed rows
function buildEscposRasterGSv0({ packed, width, height, rowBytes, cut = true }) {
    const ESC = Buffer.from([0x1B]);
    const GS  = Buffer.from([0x1D]);

    const m  = 0;                           // normal mode
    const xL = rowBytes & 0xFF;
    const xH = (rowBytes >> 8) & 0xFF;
    const yL = height & 0xFF;
    const yH = (height >> 8) & 0xFF;

    const lf2 = Buffer.from([0x0A, 0x0A]);  // \n\n as bytes
    const cutCmd = Buffer.from([0x1D, 0x56, 0x42, 0x03]); // GS V 66 3  (partial cut)

    const init   = Buffer.from([0x1B, 0x40]);                   // ESC @
    const header = Buffer.from([0x1D, 0x76, 0x30, m, xL, xH, yL, yH]); // GS v 0 m xL xH yL yH

    return Buffer.concat([
        init,
        header,
        packed,
        lf2,
        ...(cut ? [cutCmd] : []),
    ]);
}


// --- helper: TCP send ---
function sendRawToTcp({ host, port, data, timeoutMs = 8000 }) {
    return new Promise((resolve, reject) => {
        const socket = net.connect({ host, port }, () => {
            socket.write(data);
            socket.end(); // half-close after write
        });
        socket.on('error', reject);
        socket.setTimeout(timeoutMs, () => {
            socket.destroy(new Error('Printer connection timeout'));
        });
        socket.on('close', resolve);
    });
}

// --- helper: PNG buffer -> ESC/POS raster (GS v 0) ---
async function pngBufferToEscposRaster(pngBuffer, threshold = 210, invert = false) {
    // grayscale raw pixels
    const { data, info } = await sharp(pngBuffer)
        .ensureAlpha()            // in case PNG has alpha
        .removeAlpha()
        .greyscale()
        .raw()
        .toBuffer({ resolveWithObject: true });

    const { width, height } = info;
    const widthBytes = Math.ceil(width / 8);

    // pack 1-bit rows
    const raster = Buffer.alloc(widthBytes * height);
    for (let y = 0; y < height; y++) {
        const rowOffset = y * width;
        const outRow = y * widthBytes;
        for (let xByte = 0; xByte < widthBytes; xByte++) {
            let b = 0;
            for (let bit = 0; bit < 8; bit++) {
                const x = xByte * 8 + bit;
                const inRange = x < width;
                const idx = rowOffset + (inRange ? x : width - 1);
                const gray = data[idx]; // 0..255
                let isBlack = gray < threshold;
                if (invert) isBlack = !isBlack;
                b |= (isBlack ? 1 : 0) << (7 - bit);
            }
            raster[outRow + xByte] = b;
        }
    }

    // ESC/POS commands
    const ESC = 0x1b, GS = 0x1d;

    const init = Buffer.from([ESC, 0x40]); // ESC @ (initialize)
    // GS v 0 m xL xH yL yH  + data
    const m = 0x00; // normal density
    const xL = widthBytes & 0xff;
    const xH = (widthBytes >> 8) & 0xff;
    const yL = height & 0xff;
    const yH = (height >> 8) & 0xff;
    const header = Buffer.from([GS, 0x76, 0x30, m, xL, xH, yL, yH]);

    const feed = Buffer.from([0x0a, 0x0a]);       // LF x2
    const cut  = Buffer.from([GS, 0x56, 0x42, 0]); // GS V 66 0 (partial cut)

    return Buffer.concat([init, header, raster, feed, cut]);
}


/* ------------------------ Receipt HTML template (optional) ------------------------ */
function buildHTML(payload) {
    const { market = {}, header = {}, items = [], totals = {}, buyer = {}, message = '' } = payload;
    const widthPx = payload.paper_width_px || 576; // 80mm ≈ 576px; 58mm ≈ 384px

    return `<!doctype html>
<html lang="fa" dir="rtl">
<meta charset="utf-8"/>
<title>receipt</title>
<style>
  @page { size: ${Math.round(widthPx / 8)}in auto; margin: 0; }
  *{ box-sizing: border-box; }
  html,body{ background:#fff; color:#000; }
  body{
    margin:0; width:${widthPx}px;
    font-family: Tahoma, Arial, sans-serif;
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
  }
  .wrap{ padding:10px 14px; }
  .header{ text-align:center; }
  .brand{ font-weight:700; font-size:18px; margin-bottom:4px; }
  .small{ font-size:12px; line-height:1.6; }
  hr{ border:0; border-top:1px dashed #333; margin:8px 0; }
  table{ width:100%; border-collapse:collapse; }
  th,td{ font-size:12px; padding:3px 0; }
  th{ text-align:right; border-bottom:1px dashed #333; }
  .row{ display:flex; justify-content:space-between; gap:10px; }
  .mono{ font-family:"Courier New", Courier, monospace; }
  .total{ font-weight:700; }
  .thanks{ text-align:center; margin-top:8px; }
</style>
<body>
  <div class="wrap">
    <div class="header">
      <div class="brand">${market.name ?? ''}</div>
      <div class="small">
        ${market.slug ? `<div>${market.slug}</div>` : ''}
        ${market.phone ? `<div>تلفن: ${market.phone}</div>` : ''}
        ${market.address ? `<div>آدرس: ${market.address}</div>` : ''}
        ${market.operator ? `<div>صندوق: ${market.operator}</div>` : ''}
      </div>
    </div>

    <hr>

    <div class="row small">
      <div>تاریخ: ${header.date ?? ''} ${header.time ?? ''}</div>
      <div>شماره: ${header.id ?? ''}</div>
    </div>

    <table>
      <thead><tr><th>کالا</th></tr></thead>
      <tbody>
        ${items
        .map((it) => {
            const c = +it.count || 0,
                p = +it.price || 0,
                sum = Math.round(c * p);
            return `<tr><td>
              <div>${it.title ?? '—'}</div>
              <div class="small mono">
                ${it.barcode ? `*${it.barcode}*` : ''}
                ${c} ${it.unit_title ?? ''} × ${p.toLocaleString('fa-IR')}
                = <b>${sum.toLocaleString('fa-IR')}</b>
              </div>
            </td></tr>`;
        })
        .join('')}
      </tbody>
    </table>

    <hr>

    <div class="small">
      ${totals.shipping ? `<div class="row"><span>هزینه ارسال</span><span class="mono">${(+totals.shipping).toLocaleString('fa-IR')}</span></div>` : ''}
      ${totals.vat ? `<div class="row"><span>ارزش افزوده</span><span class="mono">${(+totals.vat).toLocaleString('fa-IR')} ٪</span></div>` : ''}
      <div class="row total">
        <span>مبلغ قابل پرداخت</span>
        <span class="mono">${(+totals.total || items.reduce((s, i) => s + (+i.price || 0) * (+i.count || 0), 0)).toLocaleString('fa-IR')} تومان</span>
      </div>
    </div>

    ${payload.payments?.length ? `
      <hr>
      <div class="small">
        <div><b>روش‌های پرداخت:</b></div>
        ${payload.payments
        .map((p) => {
            const map = { cash: 'نقد', pos: 'پوز', debt: 'بدهی', cheque: 'چک' };
            return `<div class="row"><span>${map[p.type] ?? 'نقد'}</span><span class="mono">${(+p.amount || 0).toLocaleString('fa-IR')}</span></div>`;
        })
        .join('')}
      </div>` : ''}

    <hr>

    <div class="small">
      <div><b>اطلاعات مشتری</b></div>
      <div>نام: ${buyer.name ?? '—'}</div>
      <div>تلفن: ${buyer.phone ?? '—'}</div>
      ${buyer.address ? `<div>آدرس: ${buyer.address}</div>` : ''}
    </div>

    ${message ? `<div class="thanks small">${message}</div>` : ''}
    <div class="thanks small">از خرید شما سپاسگزاریم 🌟</div>
  </div>
</body>
</html>`;
}

/* ------------------------ Routes ------------------------ */
app.get('/health', (req, res) => res.json({ ok: true }));

// POST /print  (JSON data -> buildHTML -> render -> raster -> print)
app.post('/print', async (req, res) => {
    try {
        const { printer_ip, printer_port = 9100, data } = req.body;
        if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip is required' });
        if (!data || !Array.isArray(data.items))
            return res.status(400).json({ ok: false, error: 'data.items (array) is required' });

        const widthPx = data.paper_width_px || 576;
        const html = buildHTML(data);

        const pngBuffer = await renderHtmlToPngBuffer(html, widthPx);
        const threshold = Number(data.threshold) || 235;
        const { width, height, rowBytes, packed } = await imageToMonochrome({
            inputBuffer: pngBuffer,
            widthLimitPx: widthPx,
            threshold,
            dither: true,
        });

        const escposBytes = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true });
        await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });

        res.json({ ok: true });
    } catch (e) {
        console.error('PRINT ERROR:', e);
        res.status(500).json({ ok: false, error: String(e?.message || e) });
    }
});

// POST /print-html  (HTML string -> render -> raster -> [preview|print])
// Body: { printer_ip, printer_port?, html, paper_width_px?, threshold?, invert?, preview? }
app.post('/print-html', async (req, res) => {
    try {
        const {
            html, printer_ip, printer_port = 9100,
            paper_width_px = 576, threshold = 235,
            invert = false, preview, mode // 'escstar' or 'raster'
        } = req.body;

        if (!html) return res.status(400).json({ ok: false, error: 'html is required' });
        if (!printer_ip && !preview) return res.status(400).json({ ok: false, error: 'printer_ip is required for printing' });

        // 1) Render HTML to PNG
        const pngBuffer = await renderHtmlToPngBuffer(html, paper_width_px);

        // 2) Binarize
        const { width, height, rowBytes, packed, mono, approxNonWhite, bmpBuffer } =
            await imageToMonochrome({ inputBuffer: pngBuffer, widthLimitPx: paper_width_px, threshold, dither: true });

        if (preview) {
            const dataUrl = `data:image/bmp;base64,${bmpBuffer.toString('base64')}`;
            return res.json({ ok: true, dataUrl, approx_nonwhite_pixels: approxNonWhite, width, height });
        }

        // 3) Build ESC/POS by mode
        let escpos;
        if (String(mode).toLowerCase() === 'escstar') {
            escpos = buildEscposBitImage_ESCStar({ mono, width, height, cut: true });
        } else {
            escpos = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true }); // GS v 0
        }

        // 4) Send
        await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escpos });
        res.json({ ok: true, width, height, approx_nonwhite_pixels: approxNonWhite, mode: mode || 'raster' });
    } catch (e) {
        console.error('print-html error:', e);
        res.status(500).json({ ok: false, error: e.message });
    }
});


// POST /test  (built-in demo)
app.post('/test', async (req, res) => {
    try {
        const { printer_ip, printer_port = 9100 } = req.body;
        if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip is required' });

        const demo = {
            paper_width_px: 576,
            market: { name: 'نمونه فروشگاه', phone: '021-12345678', address: 'تهران - خیابان مثال', operator: 'صندوق 1' },
            header: { id: 'T-' + Date.now(), date: '1404/07/26', time: '12:34:56' },
            items: [
                { title: 'نان باگت', barcode: '123', price: 12000, count: 2, unit_title: 'عدد' },
                { title: 'شیر کم‌چرب 1 لیتری', barcode: '456', price: 34000, count: 1, unit_title: 'عدد' },
            ],
            totals: { total: 58000 },
            payments: [{ type: 'pos', amount: 58000 }],
            buyer: { name: 'مشتری نمونه', phone: '09120000000' },
            message: 'خرید خوبی داشته باشید ✨',
        };

        const html = buildHTML(demo);
        const pngBuffer = await renderHtmlToPngBuffer(html, demo.paper_width_px);

        // Either path works; we use 1-bit dithering here:
        const { width, height, rowBytes, packed } = await imageToMonochrome({
            inputBuffer: pngBuffer,
            widthLimitPx: demo.paper_width_px,
            threshold: 235,
            dither: true,
        });
        const escposBytes = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true });

        await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });

        // Save a debug PNG
        const debugPath = path.join(OUT_DIR, `test_${Date.now()}.png`);
        fs.writeFileSync(debugPath, pngBuffer);

        res.json({ ok: true, debug_png: path.basename(debugPath) });
    } catch (e) {
        console.error('TEST PRINT ERROR:', e);
        res.status(500).json({ ok: false, error: String(e?.message || e) });
    }
});

// --- NEW /print-image using our own rasterizer (no escpos.Image) ---
// --- the complete route you asked for ---
app.post('/print-image', async (req, res) => {
    try {
        let {
            pngBase64,
            width = 576,          // ← default to 576 for 80mm
            threshold = 210,
            invert = false,
            printer_ip,
            printer_port = 9100
        } = req.body || {};

        if (!pngBase64) return res.status(400).json({ ok: false, error: 'pngBase64 missing' });
        if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip missing' });

        const src = Buffer.from(pngBase64, 'base64');

        let pipeline = sharp(src).flatten({ background: '#FFFFFF' });
        const meta = await pipeline.metadata();

        // 🔒 hard clamp to device width (TT200/GK888T ≈ 576 @ 203dpi)
        const MAX_WIDTH = 576;
        const targetW = Math.min(Number(width) > 0 ? Number(width) : (meta.width || MAX_WIDTH), MAX_WIDTH);

        if (targetW !== meta.width) {
            pipeline = pipeline.resize({ width: targetW, withoutEnlargement: false });
        }

        const preparedPng = await pipeline.png().toBuffer();

        const escposBytes = await pngBufferToEscposRaster(preparedPng, Number(threshold), Boolean(invert));

        await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });

        const finalMeta = await sharp(preparedPng).metadata();
        console.log('Printed PNG size:', finalMeta.width, 'x', finalMeta.height);
        return res.json({ ok: true, width: finalMeta.width, height: finalMeta.height });
    } catch (e) {
        console.error('print-image error:', e);
        return res.status(500).json({ ok: false, error: e.message || String(e) });
    }
});/* ------------------------ Start servers ------------------------ */
// HTTP (kept for local tests)
const HTTP_PORT = Number(process.env.PORT) || 9091;
app.listen(HTTP_PORT, '0.0.0.0', () => {
    console.log(`HTTP  listening on :${HTTP_PORT}`);
    for (const ip of lanIPs()) console.log(`  http://${ip}:${HTTP_PORT}`);
});

// HTTPS (to avoid mixed-content from https://hamekara.com)
try {
    const HTTPS_PORT = Number(process.env.SSL_PORT) || 9092;
    // If you used mkcert:
    const KEY_FILE = path.join(__dirname, '192.168.100.15-key.pem');
    const CERT_FILE = path.join(__dirname, '192.168.100.15.pem');
    const key = fs.readFileSync(KEY_FILE);
    const cert = fs.readFileSync(CERT_FILE);

    https.createServer({ key, cert }, app).listen(HTTPS_PORT, '0.0.0.0', () => {
        console.log(`HTTPS listening on :${HTTPS_PORT}`);
        for (const ip of lanIPs()) console.log(`  https://${ip}:${HTTPS_PORT}`);
        console.log('CORS allowed origins:', [...ALLOWED_ORIGINS].join(', '));
        console.log('Endpoints: GET /health  |  POST /print  |  POST /print-html  |  POST /test');
    });
} catch (e) {
    console.warn('HTTPS not started (missing/invalid cert). Create mkcert/OpenSSL certs and restart.');
}
