/**
 * Merge module: inject EXIF from Google Takeout JSON into JPEG.
 * Rewritten from scratch: date and location come only from metadata.
 * - DateTimeOriginal from metadata photoTakenTime (timestamp or formatted string)
 * - GPS from metadata geoDataExif / geoData
 * - UserComment: [0] Google Photos URL, [1] formatted date, [2] title/description, [3] album names, [4] people/faces
 */
(function (global) {
  'use strict';

  var EXIF_DATETIME_LEN = 19; /* "YYYY:MM:DD HH:MM:SS" */

  /**
   * Normalize raw Google Takeout JSON into a flat shape for building EXIF.
   * Returns { dateMs, formattedDate, url, commentText, lat, lon, alt, cameraMake, cameraModel }.
   * Date is taken only from metadata (photoTakenTime then creationTime); never "now".
   */
  function normalizeMeta(raw) {
    var out = { dateMs: null, formattedDate: '', url: '', commentText: '', lat: NaN, lon: NaN, alt: 0, cameraMake: '', cameraModel: '' };
    if (!raw || typeof raw !== 'object') return out;
    var topLevel = raw;
    if (raw.metadata && typeof raw.metadata === 'object') raw = raw.metadata;
    if (raw.photo && typeof raw.photo === 'object') raw = raw.photo;

    var pt = raw.photoTakenTime || raw.PhotoTakenTime;
    var ct = raw.creationTime || raw.CreationTime;

    function toMs(v) {
      if (v == null) return null;
      var n = Number(v);
      if (isNaN(n)) return null;
      return n >= 1e12 || n < -1e9 ? n : n * 1000;
    }

    if (pt && typeof pt === 'object') {
      out.dateMs = toMs(pt.timestamp);
      if (out.dateMs == null && pt.formatted) {
        out.dateMs = Date.parse(String(pt.formatted).trim());
        if (isNaN(out.dateMs)) out.dateMs = null;
      }
      out.formattedDate = pt.formatted != null ? String(pt.formatted) : '';
    } else if (raw.photoTakenTimeTimestamp != null) {
      out.dateMs = toMs(raw.photoTakenTimeTimestamp);
      out.formattedDate = raw.photoTakenTimeFormatted != null ? String(raw.photoTakenTimeFormatted) : '';
    }
    if (out.dateMs == null && ct && typeof ct === 'object') {
      out.dateMs = toMs(ct.timestamp);
      if (out.dateMs == null && ct.formatted) {
        out.dateMs = Date.parse(String(ct.formatted).trim());
        if (isNaN(out.dateMs)) out.dateMs = null;
      }
      if (out.formattedDate === '' && ct.formatted) out.formattedDate = String(ct.formatted);
    } else if (out.dateMs == null && raw.creationTimeTimestamp != null) {
      out.dateMs = toMs(raw.creationTimeTimestamp);
      if (out.formattedDate === '') out.formattedDate = raw.creationTimeFormatted != null ? String(raw.creationTimeFormatted) : '';
    }

    /* Google Photos URL: from top-level or nested (Takeout may put productUrl at root; try multiple shapes) */
    out.url = String(
      topLevel.productUrl || topLevel.url || topLevel.googlePhotoUrl || topLevel.link ||
      raw.productUrl || raw.url || raw.googlePhotoUrl || raw.sourceUrl || raw.pageUrl || raw.photoUrl || ''
    ).trim();
    var title = String(topLevel.title || raw.title || '').trim();
    var description = String(topLevel.description || raw.description || '').trim();
    var titleOrDesc = (title + ' ' + description).trim();
    var albums = topLevel.albums || raw.albums;
    var albumStr = '';
    if (Array.isArray(albums)) {
      albumStr = albums.map(function (a) { return typeof a === 'string' ? a : (a && a.name ? a.name : ''); }).filter(Boolean).join(', ');
    } else if (typeof albums === 'string') {
      albumStr = albums;
    }
    var people = topLevel.people || raw.people || raw.person;
    var peopleStr = '';
    if (Array.isArray(people)) {
      peopleStr = people.map(function (p) { return typeof p === 'string' ? p : (p && p.name ? p.name : (p && p.displayName ? p.displayName : '')); }).filter(Boolean).join(', ');
    } else if (typeof people === 'string') {
      peopleStr = people;
    }
    if (!peopleStr && raw.faceAnnotations && Array.isArray(raw.faceAnnotations)) {
      peopleStr = raw.faceAnnotations.length + ' face(s)';
    }
    var commentParts = [];
    if (out.url) commentParts.push('[0] ' + out.url);
    if (out.formattedDate) commentParts.push('[1] ' + out.formattedDate);
    if (titleOrDesc) commentParts.push('[2] ' + titleOrDesc);
    if (albumStr) commentParts.push('[3] ' + albumStr);
    if (peopleStr) commentParts.push('[4] ' + peopleStr);
    out.commentText = commentParts.join('\n').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ' ').slice(0, 2000);

    var geo = (topLevel.geoDataExif || topLevel.geoData) || (raw.geoDataExif || raw.geoData);
    if (geo && typeof geo === 'object') {
      var lat = Number(geo.latitude != null ? geo.latitude : (geo.latitudeDecimal != null ? geo.latitudeDecimal : geo.lat));
      var lon = Number(geo.longitude != null ? geo.longitude : (geo.longitudeDecimal != null ? geo.longitudeDecimal : geo.lon));
      if ((isNaN(lat) || isNaN(lon)) && Array.isArray(geo.coordinates) && geo.coordinates.length >= 2) {
        var a = Number(geo.coordinates[0]);
        var b = Number(geo.coordinates[1]);
        if (!isNaN(a) && !isNaN(b)) {
          if (Math.abs(a) <= 90 && Math.abs(b) <= 180) { lat = a; lon = b; }
          else if (Math.abs(b) <= 90 && Math.abs(a) <= 180) { lat = b; lon = a; }
        }
      }
      if (!isNaN(lat) && !isNaN(lon)) {
        var refLat = geo.latitudeRef || geo.latRef;
        var refLon = geo.longitudeRef || geo.lonRef;
        if (refLat === 'S' || refLat === 's') lat = -Math.abs(lat);
        if (refLon === 'W' || refLon === 'w') lon = -Math.abs(lon);
        if (Math.abs(lat) > 90 && Math.abs(lon) <= 90) { var t = lat; lat = lon; lon = t; }
        if (Math.abs(lon) > 180 && Math.abs(lat) <= 180) { var t = lat; lat = lon; lon = t; }
        out.lat = lat;
        out.lon = lon;
      }
      out.alt = typeof geo.altitude === 'number' ? geo.altitude : Number(geo.altitude) || 0;
    }
    /* Camera: from photo / metadata / imageMediaMetadata / top-level (Takeout and API variants) */
    var metaPhoto = (topLevel.metadata && topLevel.metadata.photo) ? topLevel.metadata.photo : null;
    var imgMeta = (raw && raw.imageMediaMetadata) ? raw.imageMediaMetadata : (topLevel.imageMediaMetadata || (metaPhoto && metaPhoto.imageMediaMetadata) || (topLevel.metadata && topLevel.metadata.imageMediaMetadata));
    var make = topLevel.cameraMake || raw.cameraMake || (raw.metadata && raw.metadata.cameraMake) || (imgMeta && imgMeta.cameraMake) || (metaPhoto && metaPhoto.cameraMake) || '';
    var model = topLevel.cameraModel || raw.cameraModel || (raw.metadata && raw.metadata.cameraModel) || (imgMeta && imgMeta.cameraModel) || (metaPhoto && metaPhoto.cameraModel) || '';
    out.cameraMake = String(make).trim().slice(0, 256);
    out.cameraModel = String(model).trim().slice(0, 256);
    return out;
  }

  /** Convert milliseconds to EXIF DateTimeOriginal string "YYYY:MM:DD HH:MM:SS" (19 chars). */
  function msToExifDateTime(ms) {
    if (ms == null || isNaN(ms)) return '2020:01:01 00:00:00';
    var d = new Date(ms);
    var y = d.getUTCFullYear();
    var m = (d.getUTCMonth() + 1);
    var day = d.getUTCDate();
    var h = d.getUTCHours();
    var min = d.getUTCMinutes();
    var s = d.getUTCSeconds();
    var str = '' + y + ':' + (m < 10 ? '0' : '') + m + ':' + (day < 10 ? '0' : '') + day + ' ' +
      (h < 10 ? '0' : '') + h + ':' + (min < 10 ? '0' : '') + min + ':' + (s < 10 ? '0' : '') + s;
    return (str + '0000:00:00 00:00:00').slice(0, EXIF_DATETIME_LEN);
  }

  function writeLongLE(n) {
    var b = new Uint8Array(4);
    b[0] = n & 0xff;
    b[1] = (n >> 8) & 0xff;
    b[2] = (n >> 16) & 0xff;
    b[3] = (n >> 24) & 0xff;
    return b;
  }
  function writeRational(num, den) {
    var b = new Uint8Array(8);
    var n = Math.floor(Number(num)) || 0;
    var d = Math.floor(Number(den)) || 1;
    b[0] = n & 0xff; b[1] = (n >> 8) & 0xff; b[2] = (n >> 16) & 0xff; b[3] = (n >> 24) & 0xff;
    b[4] = d & 0xff; b[5] = (d >> 8) & 0xff; b[6] = (d >> 16) & 0xff; b[7] = (d >> 24) & 0xff;
    return b;
  }
  function degToDmsRational(deg) {
    var degAbs = Math.abs(deg);
    var d = Math.floor(degAbs);
    var m = Math.floor((degAbs - d) * 60);
    var s = Math.round((degAbs - d - m / 60) * 3600 * 100);
    return [[d, 1], [m, 1], [s, 100]];
  }

  /**
   * Build one APP1(Exif) TIFF segment from normalized meta.
   * Layout: TIFF header (8) + IFD0 (ExifTag, optional GPSInfo) + Exif IFD (DateTimeOriginal, UserComment) + [GPS IFD] + data.
   * Returns new ArrayBuffer of exact length.
   */
  var USERCOMMENT_ASCII_PREFIX = new Uint8Array([0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00]); /* "ASCII\0\0\0" per EXIF 2.2 */

  function buildExifSegment(meta) {
    var m = normalizeMeta(meta);
    var dtStr = msToExifDateTime(m.dateMs);
    var encoder = new TextEncoder();
    var dtBytes = encoder.encode(dtStr);
    var commentBytes = encoder.encode(m.commentText);
    var commentTotalLen = USERCOMMENT_ASCII_PREFIX.length + commentBytes.length + 1; /* prefix + text + null */
    var hasGps = !isNaN(m.lat) && !isNaN(m.lon);
    var makeStr = (m.cameraMake != null && m.cameraMake !== '') ? String(m.cameraMake) : '';
    var modelStr = (m.cameraModel != null && m.cameraModel !== '') ? String(m.cameraModel) : '';
    var makeBytes = encoder.encode(makeStr);
    var modelBytes = encoder.encode(modelStr);
    var makeLen = makeBytes.length + 1;
    var modelLen = modelBytes.length + 1;

    var parts = [];
    /* TIFF header: II, 42, offset to first IFD = 8 */
    parts.push(new Uint8Array([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]));

    /* IFD0: ExifTag, [GPSInfo], Make (0x010F), Model (0x0110) */
    var ifd0Entries = 3;
    if (hasGps) ifd0Entries = 4;
    var exifIfdOff = 8 + 2 + 12 * ifd0Entries + 4;
    var exifIfdSize = 2 + 12 * 2 + 4; /* 2 entries: DateTimeOriginal, UserComment */
    var gpsIfdOff = exifIfdOff + exifIfdSize;
    var gpsIfdSize = hasGps ? (2 + 12 * 6 + 4) : 0;
    var dataOff = gpsIfdOff + gpsIfdSize;
    var dtOff = dataOff;
    var commentOff = dtOff + 20;
    var gpsDataOff = commentOff + commentTotalLen;
    var makeOff = gpsDataOff + (hasGps ? 72 : 0);
    var modelOff = makeOff + makeLen;

    /* IFD0 */
    parts.push(new Uint8Array([ifd0Entries, 0x00]));
    parts.push(new Uint8Array([0x69, 0x87, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00]));
    parts.push(writeLongLE(exifIfdOff));
    if (hasGps) {
      parts.push(new Uint8Array([0x25, 0x88, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00]));
      parts.push(writeLongLE(gpsIfdOff));
    }
    /* Make (0x010F) type 2 ASCII */
    parts.push(new Uint8Array([0x0f, 0x01, 0x02, 0x00]));
    parts.push(writeLongLE(makeLen > 0 ? makeLen : 1));
    parts.push(makeLen <= 4 ? new Uint8Array([makeBytes[0] || 0, makeBytes[1] || 0, makeBytes[2] || 0, makeBytes[3] || 0]) : writeLongLE(makeOff));
    /* Model (0x0110) type 2 ASCII */
    parts.push(new Uint8Array([0x10, 0x01, 0x02, 0x00]));
    parts.push(writeLongLE(modelLen > 0 ? modelLen : 1));
    parts.push(modelLen <= 4 ? new Uint8Array([modelBytes[0] || 0, modelBytes[1] || 0, modelBytes[2] || 0, modelBytes[3] || 0]) : writeLongLE(modelOff));
    parts.push(new Uint8Array([0x00, 0x00, 0x00, 0x00]));

    /* Exif IFD: DateTimeOriginal (0x9003), UserComment (0x9286) */
    parts.push(new Uint8Array([0x02, 0x00]));
    parts.push(new Uint8Array([0x03, 0x90, 0x02, 0x00])); /* tag 0x9003, type 2 ASCII */
    parts.push(writeLongLE(20));
    parts.push(writeLongLE(dtOff));
    parts.push(new Uint8Array([0x86, 0x92, 0x07, 0x00])); /* tag 0x9286, type 7 UNDEFINED */
    parts.push(writeLongLE(commentTotalLen));
    parts.push(writeLongLE(commentOff));
    parts.push(new Uint8Array([0x00, 0x00, 0x00, 0x00]));

    if (hasGps) {
      var latRef = m.lat >= 0 ? 0x4e : 0x53;
      var lonRef = m.lon >= 0 ? 0x45 : 0x57;
      var latDms = degToDmsRational(m.lat);
      var lonDms = degToDmsRational(m.lon);
      parts.push(new Uint8Array([0x06, 0x00]));
      parts.push(new Uint8Array([0x01, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, latRef, 0x00, 0x00, 0x00]));
      parts.push(new Uint8Array([0x02, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, 0x00]));
      parts.push(writeLongLE(gpsDataOff));
      parts.push(new Uint8Array([0x03, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, lonRef, 0x00, 0x00, 0x00]));
      parts.push(new Uint8Array([0x04, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, 0x00]));
      parts.push(writeLongLE(gpsDataOff + 24));
      parts.push(new Uint8Array([0x05, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, m.alt < 0 ? 0x01 : 0x00, 0x00, 0x00, 0x00]));
      parts.push(new Uint8Array([0x06, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00]));
      parts.push(writeLongLE(gpsDataOff + 48));
      parts.push(new Uint8Array([0x00, 0x00, 0x00, 0x00]));
    }

    /* Data area: DateTimeOriginal (20 bytes), UserComment (8-byte ASCII prefix + text + null), then GPS rationals if any */
    parts.push(dtBytes);
    parts.push(new Uint8Array([0x00]));
    parts.push(USERCOMMENT_ASCII_PREFIX);
    parts.push(commentBytes);
    parts.push(new Uint8Array([0x00]));
    if (hasGps) {
      var latDms = degToDmsRational(m.lat);
      var lonDms = degToDmsRational(m.lon);
      parts.push(writeRational(latDms[0][0], latDms[0][1]));
      parts.push(writeRational(latDms[1][0], latDms[1][1]));
      parts.push(writeRational(latDms[2][0], latDms[2][1]));
      parts.push(writeRational(lonDms[0][0], lonDms[0][1]));
      parts.push(writeRational(lonDms[1][0], lonDms[1][1]));
      parts.push(writeRational(lonDms[2][0], lonDms[2][1]));
      parts.push(writeRational(Math.round(Math.abs(m.alt) * 100), 100));
    }
    /* Make and Model strings (null-terminated) in data area */
    parts.push(makeBytes);
    parts.push(new Uint8Array([0]));
    parts.push(modelBytes);
    parts.push(new Uint8Array([0]));

    var total = 0;
    for (var i = 0; i < parts.length; i++) total += parts[i].length;
    var out = new Uint8Array(total);
    var off = 0;
    for (var j = 0; j < parts.length; j++) {
      out.set(parts[j], off);
      off += parts[j].length;
    }
    var buf = new ArrayBuffer(out.byteLength);
    new Uint8Array(buf).set(out);
    return buf;
  }

  function findJpegApp1Exif(buffer) {
    var arr = new Uint8Array(buffer);
    if (arr.length < 12 || arr[0] !== 0xff || arr[1] !== 0xd8) return -1;
    var i = 2;
    while (i < arr.length - 1) {
      if (arr[i] !== 0xff) break;
      var marker = arr[i + 1];
      if (marker === 0xd9 || marker === 0xda) break;
      i += 2;
      if (i + 2 > arr.length) break;
      var len = (arr[i] << 8) | arr[i + 1];
      if (marker === 0xe1 && len >= 8 &&
          arr[i + 2] === 0x45 && arr[i + 3] === 0x78 && arr[i + 4] === 0x69 && arr[i + 5] === 0x66)
        return i;
      i += len;
    }
    return -1;
  }

  /** Strip all APP1(Exif) segments from JPEG. Return new ArrayBuffer. */
  function stripExif(jpegBuffer) {
    var arr = new Uint8Array(jpegBuffer);
    if (arr.length < 2 || arr[0] !== 0xff || arr[1] !== 0xd8) return jpegBuffer;
    var out = [];
    var i = 2;
    while (i < arr.length - 1) {
      if (arr[i] !== 0xff) break;
      var marker = arr[i + 1];
      if (marker === 0xd9) break;
      if (marker === 0xda) {
        var start = i;
        i += 2;
        if (i + 2 <= arr.length) i += (arr[i] << 8) | arr[i + 1];
        while (i < arr.length - 1) {
          if (arr[i] === 0xff && arr[i + 1] === 0xd9) { i += 2; break; }
          if (arr[i] === 0xff && arr[i + 1] !== 0x00) { i += 2; continue; }
          i++;
        }
        out.push(arr.subarray(start, i));
        break;
      }
      i += 2;
      if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
        out.push(arr.subarray(i - 2, i));
        continue;
      }
      if (i + 2 > arr.length) break;
      var len = (arr[i] << 8) | arr[i + 1];
      var segStart = i - 2;
      var segEnd = segStart + 2 + len;
      var isApp1Exif = (marker === 0xe1 && len >= 8 &&
        arr[i + 2] === 0x45 && arr[i + 3] === 0x78 && arr[i + 4] === 0x69 && arr[i + 5] === 0x66);
      if (!isApp1Exif) out.push(arr.subarray(segStart, segEnd));
      i = segEnd;
    }
    var total = 2;
    for (var p = 0; p < out.length; p++) total += out[p].length;
    var result = new Uint8Array(total);
    result.set(arr.subarray(0, 2), 0);
    var off = 2;
    for (var q = 0; q < out.length; q++) {
      result.set(out[q], off);
      off += out[q].length;
    }
    var buf = new ArrayBuffer(result.byteLength);
    new Uint8Array(buf).set(result);
    return buf;
  }

  /** Build new JPEG: SOI + APP1(Exif from segment) + rest of image. Return new ArrayBuffer. */
  function injectExifIntoJpeg(jpegBuffer, exifSegment) {
    var arr = new Uint8Array(jpegBuffer);
    var payloadLen = 2 + 6 + exifSegment.byteLength;
    var app1 = new Uint8Array(2 + payloadLen);
    app1[0] = 0xff;
    app1[1] = 0xe1;
    app1[2] = (payloadLen >> 8) & 0xff;
    app1[3] = payloadLen & 0xff;
    app1[4] = 0x45;
    app1[5] = 0x78;
    app1[6] = 0x69;
    app1[7] = 0x66;
    app1[8] = 0;
    app1[9] = 0;
    app1.set(new Uint8Array(exifSegment), 10);

    var idx = findJpegApp1Exif(jpegBuffer);
    var outLen;
    if (idx >= 0) {
      var segLen = 2 + ((arr[idx] << 8) | arr[idx + 1]);
      outLen = arr.length - segLen + app1.length;
    } else {
      outLen = 2 + app1.length + (arr.length - 2);
    }
    var out = new Uint8Array(outLen);
    if (idx >= 0) {
      var segStart = idx - 2;
      var segLen = 2 + ((arr[idx] << 8) | arr[idx + 1]);
      out.set(arr.subarray(0, segStart), 0);
      out.set(app1, segStart);
      out.set(arr.subarray(segStart + segLen), segStart + app1.length);
    } else {
      out.set(arr.subarray(0, 2), 0);
      out.set(app1, 2);
      out.set(arr.subarray(2), 2 + app1.length);
    }
    var buf = new ArrayBuffer(out.byteLength);
    new Uint8Array(buf).set(out);
    return buf;
  }

  /** Convert ArrayBuffer to base64 data URL (chunked for large buffers). */
  function arrayBufferToDataUrl(arrayBuffer) {
    var bytes = new Uint8Array(arrayBuffer);
    var chunk = 8192;
    var binary = '';
    for (var i = 0; i < bytes.length; i += chunk) {
      var end = Math.min(i + chunk, bytes.length);
      binary += String.fromCharCode.apply(null, bytes.subarray(i, end));
    }
    var base64 = (typeof btoa !== 'undefined' ? btoa : function (b) { throw new Error('btoa not available'); })(binary);
    return 'data:image/jpeg;base64,' + base64;
  }

  /** Convert data URL (base64) to ArrayBuffer. */
  function dataUrlToArrayBuffer(dataUrl) {
    var base64 = dataUrl.indexOf(',') >= 0 ? dataUrl.split(',')[1] : dataUrl;
    var binary = (typeof atob !== 'undefined' ? atob : function (b) { throw new Error('atob not available'); })(base64);
    var len = binary.length;
    var bytes = new Uint8Array(len);
    for (var i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
    return bytes.buffer;
  }

  /**
   * Overlay JSON metadata onto existing image EXIF using piexif (when available).
   * Preserves original EXIF (e.g. Make/Model), overwrites DateTime, GPS, UserComment from JSON.
   * Returns new ArrayBuffer or null on failure (caller should fallback to strip+rebuild).
   */
  function mergeExifFromJsonOverlay(jpegArrayBuffer, metaJson) {
    var piexif = (typeof global !== 'undefined' && global.piexif) || (typeof window !== 'undefined' && window.piexif) || null;
    if (!piexif || !piexif.load || !piexif.dump || !piexif.insert) return null;
    var jpegStr;
    try {
      jpegStr = arrayBufferToDataUrl(jpegArrayBuffer);
    } catch (e) { return null; }
    var exifObj;
    try {
      exifObj = piexif.load(jpegStr);
    } catch (err) {
      exifObj = { '0th': {}, 'Exif': {}, 'GPS': {}, 'Interop': {}, '1st': {}, 'thumbnail': null };
    }
    if (!exifObj['0th']) exifObj['0th'] = {};
    if (!exifObj['Exif']) exifObj['Exif'] = {};
    if (!exifObj['GPS']) exifObj['GPS'] = {};
    var norm = normalizeMeta(metaJson);
    var dtStr = msToExifDateTime(norm.dateMs);
    exifObj['0th'][piexif.ImageIFD.DateTime] = dtStr;
    exifObj['Exif'][piexif.ExifIFD.DateTimeOriginal] = dtStr;
    exifObj['Exif'][piexif.ExifIFD.DateTimeDigitized] = dtStr;
    var commentText = (norm.commentText && norm.commentText.length) ? norm.commentText : '[0] ' + (norm.url || '');
    exifObj['Exif'][piexif.ExifIFD.UserComment] = commentText.slice(0, 2000);
    if (norm.cameraMake) exifObj['0th'][piexif.ImageIFD.Make] = norm.cameraMake;
    if (norm.cameraModel) exifObj['0th'][piexif.ImageIFD.Model] = norm.cameraModel;
    if (!isNaN(norm.lat) && !isNaN(norm.lon)) {
      exifObj['GPS'][piexif.GPSIFD.GPSLatitudeRef] = norm.lat >= 0 ? 'N' : 'S';
      exifObj['GPS'][piexif.GPSIFD.GPSLongitudeRef] = norm.lon >= 0 ? 'E' : 'W';
      exifObj['GPS'][piexif.GPSIFD.GPSLatitude] = piexif.GPSHelper.degToDmsRational(Math.abs(norm.lat));
      exifObj['GPS'][piexif.GPSIFD.GPSLongitude] = piexif.GPSHelper.degToDmsRational(Math.abs(norm.lon));
      if (norm.alt != null && !isNaN(norm.alt)) {
        exifObj['GPS'][piexif.GPSIFD.GPSAltitudeRef] = norm.alt < 0 ? 1 : 0;
        exifObj['GPS'][piexif.GPSIFD.GPSAltitude] = [Math.round(Math.abs(norm.alt) * 1000), 1000];
      }
      if (norm.dateMs != null && !isNaN(norm.dateMs)) {
        var d = new Date(norm.dateMs);
        var pad = function (n) { return (n < 10 ? '0' : '') + n; };
        exifObj['GPS'][piexif.GPSIFD.GPSDateStamp] =
          d.getUTCFullYear() + ':' + pad(d.getUTCMonth() + 1) + ':' + pad(d.getUTCDate());
        exifObj['GPS'][piexif.GPSIFD.GPSTimeStamp] = [
          [d.getUTCHours(), 1],
          [d.getUTCMinutes(), 1],
          [d.getUTCSeconds(), 1]
        ];
      }
    }
    var exifBytes;
    try {
      exifBytes = piexif.dump(exifObj);
    } catch (e) { return null; }
    var newJpegStr;
    try {
      newJpegStr = piexif.insert(exifBytes, jpegStr);
    } catch (e) { return null; }
    if (newJpegStr.slice(0, 22) === 'data:image/jpeg;base64,' || newJpegStr.slice(0, 23) === 'data:image/jpeg;base64,') {
      return dataUrlToArrayBuffer(newJpegStr);
    }
    return null;
  }

  /**
   * Merge: build new image from JPEG buffer + metadata JSON.
   * Uses overlay (preserve original EXIF, set date/GPS/UserComment from JSON) when piexif is available;
   * otherwise strips existing EXIF and rebuilds from JSON only.
   * Returns new ArrayBuffer to save as the merged file.
   */
  function injectExifFromGoogleJson(jpegArrayBuffer, metaJson) {
    var overlay = null;
    try {
      overlay = mergeExifFromJsonOverlay(jpegArrayBuffer, metaJson);
    } catch (e) { overlay = null; }
    if (overlay && overlay.byteLength > 0) return overlay;
    var stripped = findJpegApp1Exif(jpegArrayBuffer) >= 0 ? stripExif(jpegArrayBuffer) : jpegArrayBuffer;
    var copy = new ArrayBuffer(stripped.byteLength);
    new Uint8Array(copy).set(new Uint8Array(stripped));
    var exifSegment = buildExifSegment(metaJson);
    return injectExifIntoJpeg(copy, exifSegment);
  }

  /** Read DateTimeOriginal from JPEG APP1(Exif). For logging. */
  function getExifDateTimeOriginal(jpegBuffer) {
    var idx = findJpegApp1Exif(jpegBuffer);
    if (idx < 0) return '(no EXIF)';
    var arr = new Uint8Array(jpegBuffer);
    var segStart = idx - 2;
    var segLen = 2 + ((arr[idx] << 8) | arr[idx + 1]);
    var tiffStart = segStart + 10;
    if (tiffStart + 8 > arr.length || segLen < 18) return '(segment too short)';
    var le = arr[tiffStart] === 0x49 && arr[tiffStart + 1] === 0x49;
    function r16(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8)) : ((arr[p] << 8) | arr[p + 1]); }
    function r32(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8) | (arr[p + 2] << 16) | (arr[p + 3] << 24)) >>> 0 : ((arr[p] << 24) | (arr[p + 1] << 16) | (arr[p + 2] << 8) | arr[p + 3]) >>> 0; }
    var ifd0Off = r32(4);
    if (tiffStart + ifd0Off + 2 > segStart + segLen) return '(no IFD0)';
    var numEntries = r16(ifd0Off);
    var exifIfdOff = null;
    for (var e = 0; e < numEntries; e++) {
      var ent = ifd0Off + 2 + e * 12;
      if (r16(ent) !== 0x8769) continue;
      exifIfdOff = r32(ent + 8);
      break;
    }
    if (exifIfdOff == null) return '(no Exif IFD)';
    var exifNum = r16(exifIfdOff);
    for (var k = 0; k < exifNum; k++) {
      var ent = exifIfdOff + 2 + k * 12;
      if (r16(ent) !== 0x9003) continue;
      var type = r16(ent + 2);
      var count = r32(ent + 4);
      var valOff = r32(ent + 8);
      if (type === 2 && count >= 19) {
        var start = tiffStart + valOff;
        if (start + 20 > arr.length) return '(OOB)';
        var s = '';
        for (var c = 0; c < 20 && arr[start + c]; c++) s += String.fromCharCode(arr[start + c]);
        return s.trim() || '(empty)';
      }
      if (type === 2 && count <= 4) {
        var s = '';
        for (var c = 0; c < count && c < 4; c++) s += String.fromCharCode(arr[tiffStart + ent + 8 + c]);
        return s.trim() || '(empty)';
      }
    }
    return '(no DateTimeOriginal)';
  }

  /** Read GPS latitude/longitude from JPEG APP1(Exif). Returns { lat, lon } in degrees or null. */
  function getExifGps(jpegBuffer) {
    var idx = findJpegApp1Exif(jpegBuffer);
    if (idx < 0) return null;
    var arr = new Uint8Array(jpegBuffer);
    var segStart = idx - 2;
    var segLen = 2 + ((arr[idx] << 8) | arr[idx + 1]);
    var tiffStart = segStart + 10;
    if (tiffStart + 8 > arr.length || segLen < 18) return null;
    var le = arr[tiffStart] === 0x49 && arr[tiffStart + 1] === 0x49;
    function r16(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8)) : ((arr[p] << 8) | arr[p + 1]); }
    function r32(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8) | (arr[p + 2] << 16) | (arr[p + 3] << 24)) >>> 0 : ((arr[p] << 24) | (arr[p + 1] << 16) | (arr[p + 2] << 8) | arr[p + 3]) >>> 0; }
    function readRational(off) {
      var num = r32(off);
      var den = r32(off + 4);
      return den ? num / den : 0;
    }
    var ifd0Off = r32(4);
    if (tiffStart + ifd0Off + 2 > segStart + segLen) return null;
    var numEntries = r16(ifd0Off);
    var gpsIfdOff = null;
    for (var e = 0; e < numEntries; e++) {
      var ent = ifd0Off + 2 + e * 12;
      if (r16(ent) === 0x8825) {
        gpsIfdOff = r32(ent + 8);
        break;
      }
    }
    if (gpsIfdOff == null || tiffStart + gpsIfdOff + 2 > segStart + segLen) return null;
    var gpsNum = r16(gpsIfdOff);
    var latRef = 0x4e;
    var lonRef = 0x45;
    var latOff = null;
    var lonOff = null;
    for (var k = 0; k < gpsNum; k++) {
      var ent = gpsIfdOff + 2 + k * 12;
      var tag = r16(ent);
      var type = r16(ent + 2);
      var count = r32(ent + 4);
      var valOff = r32(ent + 8);
      if (tag === 0x0001 && (type === 1 || type === 2)) {
        if (type === 1) latRef = r16(ent + 8);
        else if (count <= 4) latRef = arr[tiffStart + ent + 8];
        else latRef = arr[tiffStart + valOff];
      } else if (tag === 0x0003 && (type === 1 || type === 2)) {
        if (type === 1) lonRef = r16(ent + 8);
        else if (count <= 4) lonRef = arr[tiffStart + ent + 8];
        else lonRef = arr[tiffStart + valOff];
      } else if (tag === 0x0002 && type === 5 && count === 3) {
        latOff = valOff;
      } else if (tag === 0x0004 && type === 5 && count === 3) {
        lonOff = valOff;
      }
    }
    if (latOff == null || lonOff == null) return null;
    var latDeg = readRational(latOff);
    var latMin = readRational(latOff + 8);
    var latSec = readRational(latOff + 16);
    var lat = latDeg + latMin / 60 + latSec / 3600;
    if (latRef === 0x53) lat = -lat;
    var lonDeg = readRational(lonOff);
    var lonMin = readRational(lonOff + 8);
    var lonSec = readRational(lonOff + 16);
    var lon = lonDeg + lonMin / 60 + lonSec / 3600;
    if (lonRef === 0x57) lon = -lon;
    return { lat: lat, lon: lon };
  }

  /** Read Make (0x010F) from IFD0 in JPEG APP1(Exif). Returns string or empty. */
  function getExifMake(jpegBuffer) {
    return readExifAsciiTag(jpegBuffer, 0x010f);
  }

  /** Read Model (0x0110) from IFD0 in JPEG APP1(Exif). Returns string or empty. */
  function getExifModel(jpegBuffer) {
    return readExifAsciiTag(jpegBuffer, 0x0110);
  }

  function readExifAsciiTag(jpegBuffer, tagId) {
    var idx = findJpegApp1Exif(jpegBuffer);
    if (idx < 0) return '';
    var arr = new Uint8Array(jpegBuffer);
    var segStart = idx - 2;
    var segLen = 2 + ((arr[idx] << 8) | arr[idx + 1]);
    var tiffStart = segStart + 10;
    if (tiffStart + 8 > arr.length || segLen < 18) return '';
    var le = arr[tiffStart] === 0x49 && arr[tiffStart + 1] === 0x49;
    function r16(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8)) : ((arr[p] << 8) | arr[p + 1]); }
    function r32(o) { var p = tiffStart + o; return le ? (arr[p] | (arr[p + 1] << 8) | (arr[p + 2] << 16) | (arr[p + 3] << 24)) >>> 0 : ((arr[p] << 24) | (arr[p + 1] << 16) | (arr[p + 2] << 8) | arr[p + 3]) >>> 0; }
    var ifd0Off = r32(4);
    if (tiffStart + ifd0Off + 2 > segStart + segLen) return '';
    var numEntries = r16(ifd0Off);
    for (var e = 0; e < numEntries; e++) {
      var ent = ifd0Off + 2 + e * 12;
      if (r16(ent) !== tagId) continue;
      var type = r16(ent + 2);
      var count = r32(ent + 4);
      if (type !== 2) return '';
      var valOff = r32(ent + 8);
      if (count <= 0) return '';
      var maxLen = Math.min(count, 256);
      if (count <= 4) {
        var s = '';
        for (var c = 0; c < maxLen && c < 4; c++) {
          var b = arr[tiffStart + ent + 8 + c];
          if (!b) break;
          s += String.fromCharCode(b);
        }
        return s;
      }
      var start = tiffStart + valOff;
      if (start + maxLen > segStart + segLen) return '';
      var s = '';
      for (var c = 0; c < maxLen && arr[start + c]; c++) s += String.fromCharCode(arr[start + c]);
      return s;
    }
    return '';
  }

  global.GphotoExif = {
    injectExifFromGoogleJson: injectExifFromGoogleJson,
    buildExifSegment: buildExifSegment,
    stripExif: stripExif,
    getExifDateTimeOriginal: getExifDateTimeOriginal,
    getExifGps: getExifGps,
    getExifMake: getExifMake,
    getExifModel: getExifModel,
    normalizeMeta: normalizeMeta
  };
})(typeof self !== 'undefined' ? self : this);
