/**
 * Albums-maps phase: buildAlbums(), summary report, map generation (global, per-year, per-album from manifest).
 * Standalone map functions: runGlobalMap, runPerYearMaps, runPerAlbumMapsFromManifests, runMapGeneration.
 * Album maps always use manifest.json (canonical_paths).
 */
(function () {
  'use strict';

  var g = typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : this);
  g.GphotoPipelinePhases = g.GphotoPipelinePhases || {};

  function validGps(gps) {
    if (!gps || typeof gps.lat !== 'number' || typeof gps.lon !== 'number') return false;
    var lat = gps.lat, lon = gps.lon;
    if (lat === 0 && lon === 0) return false;
    if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return false;
    return true;
  }

  function getSortableTime(rec) {
    if (!rec) return 0;
    var ms = recordTimestampToMs(rec);
    return ms != null ? ms : 0;
  }

  /** Convert record json_timestamp or exif_timestamp to milliseconds. Handles Unix seconds, ms, and date strings. */
  function recordTimestampToMs(rec) {
    if (!rec) return null;
    var s = rec.json_timestamp;
    if (s != null && s !== '') {
      s = String(s).trim();
      if (/^\d+$/.test(s)) {
        var n = parseInt(s, 10);
        if (n >= 1e12) return n;
        if (n > 0) return n * 1000;
      }
      var d = Date.parse(s.replace(' ', 'T'));
      if (!isNaN(d)) return d;
    }
    s = rec.exif_timestamp;
    if (s != null && typeof s === 'string' && /^\d{4}:\d{2}:\d{2}\s+\d{2}:\d{2}:\d{2}/.test(s.trim())) {
      var iso = s.trim().slice(0, 10).replace(/:/g, '-') + 'T' + s.trim().slice(11);
      var d = Date.parse(iso);
      if (!isNaN(d)) return d;
    }
    return null;
  }

  function buildMapHtmlString(data) {
    var dataStr = JSON.stringify(data).replace(/<\//g, '<\\/');
    var viewerCss = '#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:9999;flex-direction:column;align-items:center;justify-content:center;}' +
      '#imageViewer.show{display:flex;}' +
      '#viewerImg{max-width:95vw;max-height:80vh;object-fit:contain;}' +
      '#viewerToolbar{display:flex;align-items:center;gap:12px;margin-top:8px;}' +
      '#viewerToolbar button{background:#333;color:#fff;border:none;padding:8px 16px;cursor:pointer;font-size:18px;border-radius:4px;}' +
      '#viewerToolbar button:hover{background:#555;}' +
      '#viewerToolbar button:disabled{opacity:0.4;cursor:not-allowed;}' +
      '#viewerCaption{color:#ccc;margin-left:8px;}' +
      '.map-open-viewer{color:#1a73e8;cursor:pointer;text-decoration:underline;}';
    var viewerHtml = '<div id="imageViewer" aria-hidden="true">' +
      '<img id="viewerImg" alt="">' +
      '<div id="viewerToolbar">' +
      '<button type="button" id="viewerPrev" title="Previous (time order)">&#9664;</button>' +
      '<button type="button" id="viewerClose" title="Close">&#215;</button>' +
      '<button type="button" id="viewerNext" title="Next (time order)">&#9654;</button>' +
      '<span id="viewerCaption"></span></div></div>';
    return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Photo locations</title>' +
      '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />' +
      '<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />' +
      '<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />' +
      '<style>' + viewerCss + '</style></head><body>' +
      '<div id="map" style="height:100vh;width:100%"></div>' +
      viewerHtml +
      '<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"><\/script>' +
      '<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"><\/script>' +
      '<script>var mapData=' + dataStr + ';' +
      '(function(){var map=L.map("map",{zoomControl:false}).setView([20,0],2);' +
      'L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"&copy; OpenStreetMap"}).addTo(map);' +
      'L.control.zoom({position:"topright"}).addTo(map);' +
      'function esc(s){return String(s).replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}' +
      'var markers=L.markerClusterGroup({chunkedLoading:true,maxClusterRadius:50});' +
      'mapData.forEach(function(p,i){var popup=\'<a href="#" class="map-open-viewer" data-index="\'+i+\'">\'+esc(p.filename)+\'</a>\';' +
      'var m=L.marker([p.lat,p.lon]).bindPopup(popup);markers.addLayer(m);});' +
      'map.addLayer(markers);' +
      'var viewer=document.getElementById("imageViewer");var img=document.getElementById("viewerImg");var cap=document.getElementById("viewerCaption");' +
      'var curIdx=0;function showViewer(idx){curIdx=idx;viewer.classList.add("show");viewer.setAttribute("aria-hidden","false");' +
      'img.src=mapData[curIdx].path;cap.textContent=(curIdx+1)+" / "+mapData.length+" \u2014 "+mapData[curIdx].filename;' +
      'document.getElementById("viewerPrev").disabled=curIdx<=0;document.getElementById("viewerNext").disabled=curIdx>=mapData.length-1;} ' +
      'function hideViewer(){viewer.classList.remove("show");viewer.setAttribute("aria-hidden","true");}' +
      'document.addEventListener("click",function(e){var a=e.target.closest(".map-open-viewer");if(a){e.preventDefault();showViewer(parseInt(a.getAttribute("data-index"),10));}});' +
      'document.getElementById("viewerPrev").onclick=function(){if(curIdx>0)showViewer(curIdx-1);};' +
      'document.getElementById("viewerNext").onclick=function(){if(curIdx<mapData.length-1)showViewer(curIdx+1);};' +
      'document.getElementById("viewerClose").onclick=hideViewer;' +
      'document.addEventListener("keydown",function(e){if(!viewer.classList.contains("show"))return;' +
      'if(e.key==="Escape"){hideViewer();e.preventDefault();}' +
      'else if(e.key==="ArrowLeft"){if(curIdx>0){showViewer(curIdx-1);e.preventDefault();}}' +
      'else if(e.key==="ArrowRight"){if(curIdx<mapData.length-1){showViewer(curIdx+1);e.preventDefault();}}});})();' +
      '<\/script></body></html>';
  }

  function buildMapDataForEntries(entries, pathPrefix, getFileFromProcessedByPath, processedHandle) {
    var mapData = [];
    var needExifList = [];
    for (var ei = 0; ei < entries.length; ei++) {
      var e = entries[ei];
      if (validGps(e.rec && e.rec.json_gps)) {
        mapData.push({ lat: e.rec.json_gps.lat, lon: e.rec.json_gps.lon, path: pathPrefix + e.path.replace(/\\/g, '/'), filename: e.filename, taken_at: getSortableTime(e.rec) });
      } else {
        needExifList.push(e);
      }
    }
    var innerSeq = Promise.resolve();
    needExifList.forEach(function (entry) {
      innerSeq = innerSeq.then(function () {
        return getFileFromProcessedByPath(processedHandle, entry.path).then(function (buf) {
          var gps = (typeof GphotoExif !== 'undefined' && GphotoExif.getExifGps) ? GphotoExif.getExifGps(buf) : null;
          if (validGps(gps)) {
            mapData.push({ lat: gps.lat, lon: gps.lon, path: pathPrefix + entry.path.replace(/\\/g, '/'), filename: entry.filename, taken_at: getSortableTime(entry.rec) });
          }
          return Promise.resolve();
        }).catch(function () { return Promise.resolve(); });
      });
    });
    return innerSeq.then(function () {
      mapData.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
      return mapData;
    });
  }

  /** For standalone: create getOrCreatePath and getFileFromProcessedByPath from processedHandle only. */
  function ensureDirLocal(parent, name) {
    var safe = name.replace(/[\x00-\x1f\\/:*?"<>|]/g, '_').trim() || '_';
    return parent.getDirectoryHandle(safe, { create: true }).catch(function () {
      return parent.getDirectoryHandle(safe);
    });
  }
  function getOrCreatePathLocal(rootHandle, pathStr) {
    if (!pathStr) return Promise.resolve(rootHandle);
    var parts = pathStr.split('/').filter(Boolean);
    var p = Promise.resolve(rootHandle);
    for (var i = 0; i < parts.length; i++) {
      (function (part) {
        p = p.then(function (h) { return ensureDirLocal(h, part); });
      })(parts[i]);
    }
    return p;
  }
  function sanitizePartForFS(name) {
    if (name == null || typeof name !== 'string') return '_';
    var s = name.replace(/[\x00-\x1f\\/:*?"<>|]/g, '_').trim();
    return s.length ? s : '_';
  }
  function getFileFromProcessedByPathLocal(procHandle, relativePath) {
    var parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
    if (parts.length === 0) return Promise.reject(new Error('empty path'));
    if (parts.length === 1) {
      var safe0 = sanitizePartForFS(parts[0]);
      return procHandle.getFileHandle(safe0).then(function (h) { return h.getFile(); }).then(function (f) { return f.arrayBuffer(); });
    }
    var dirPath = parts.slice(0, -1).join('/');
    var fileName = sanitizePartForFS(parts[parts.length - 1]);
    return getOrCreatePathLocal(procHandle, dirPath).then(function (dir) {
      return dir.getFileHandle(fileName).then(function (h) { return h.getFile(); }).then(function (f) { return f.arrayBuffer(); });
    });
  }
  function writeFileToDirLocal(dirHandle, name, blobOrBuffer) {
    var safe = name.replace(/[\x00-\x1f\\/:*?"<>|]/g, '_').trim() || '_';
    return dirHandle.getFileHandle(safe, { create: true }).then(function (h) {
      return h.createWritable();
    }).then(function (w) {
      var p = w.write(blobOrBuffer instanceof ArrayBuffer ? blobOrBuffer : blobOrBuffer);
      return (p && typeof p.then === 'function' ? p : Promise.resolve()).then(function () {
        return w.close();
      });
    });
  }

  function isYearLikeAlbumName(name) {
    if (!name || typeof name !== 'string') return false;
    var t = name.trim();
    if (/^\d{4}$/.test(t)) return true;
    if (/^Photos from \d{4}$/i.test(t)) return true;
    return false;
  }

  /** Per-album maps from manifest.json only (no index). */
  function runPerAlbumMapsFromManifests(processedHandle, options, log) {
    log = log || function () {};
    if (!options.generateMap) return Promise.resolve();
    return processedHandle.getDirectoryHandle('Albums').catch(function () { return null; }).then(function (albumsDir) {
      if (!albumsDir) return Promise.resolve([]);
      var it = albumsDir.entries();
      var names = [];
      function next() {
        var n = it.next();
        var prom = n && typeof n.then === 'function' ? n : Promise.resolve(n);
        return prom.then(function (r) {
          if (r.done) return names;
          if (r.value[1].kind === 'directory') names.push(r.value[0]);
          return next();
        });
      }
      return next();
    }).then(function (albumNames) {
      if (!albumNames || albumNames.length === 0) return Promise.resolve();
      var seq = Promise.resolve();
      albumNames.forEach(function (safeName) {
        seq = seq.then(function () {
          return processedHandle.getDirectoryHandle('Albums').then(function (albumsDir) {
            return albumsDir.getDirectoryHandle(safeName);
          }).then(function (albumDir) {
            return albumDir.getFileHandle('manifest.json').then(function (h) {
              return h.getFile().then(function (f) { return f.text(); });
            }).then(function (t) {
              var manifest;
              try { manifest = JSON.parse(t); } catch (e) { return null; }
              var paths = manifest && manifest.canonical_paths;
              if (!paths || !Array.isArray(paths) || paths.length === 0) return null;
              var entries = [];
              for (var i = 0; i < paths.length; i++) {
                var rel = paths[i];
                if (typeof rel !== 'string') continue;
                var processedRelative = rel.replace(/^\.\.\/\.\.\//, '').replace(/\\/g, '/');
                if (!processedRelative) continue;
                entries.push({ path: rel.replace(/\\/g, '/'), processedRelative: processedRelative, filename: processedRelative.split('/').pop() || '?' });
              }
              if (entries.length === 0) return null;
              return albumDir.getFileHandle('map_data.json').then(function (h) {
                return h.getFile().then(function (f) { return f.text(); });
              }).then(function (jsonText) {
                var byPath = {};
                try {
                  var loaded = JSON.parse(jsonText);
                  if (Array.isArray(loaded)) loaded.forEach(function (e) { byPath[e.path] = e; });
                } catch (e) {}
                return byPath;
              }).catch(function () { return {}; }).then(function (existingByPath) {
                var mapData = [];
                entries.forEach(function (entry) {
                  if (existingByPath[entry.path]) mapData.push(existingByPath[entry.path]);
                });
                var needFetch = entries.filter(function (entry) { return !existingByPath[entry.path]; });
                var seq2 = Promise.resolve();
                needFetch.forEach(function (entry) {
                  seq2 = seq2.then(function () {
                    return getFileFromProcessedByPathLocal(processedHandle, entry.processedRelative).then(function (buf) {
                      var gps = (typeof GphotoExif !== 'undefined' && GphotoExif.getExifGps) ? GphotoExif.getExifGps(buf) : null;
                      if (validGps(gps)) {
                        mapData.push({ lat: gps.lat, lon: gps.lon, path: entry.path, filename: entry.filename, taken_at: 0 });
                      }
                      return Promise.resolve();
                    }).catch(function () { return Promise.resolve(); });
                  });
                });
                return seq2.then(function () {
                  mapData.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
                  if (mapData.length === 0) return Promise.resolve();
                  var json = JSON.stringify(mapData, null, 2);
                  var html = buildMapHtmlString(mapData);
                  return writeFileToDirLocal(albumDir, 'map_data.json', new TextEncoder().encode(json)).then(function () {
                    return writeFileToDirLocal(albumDir, 'map.html', new TextEncoder().encode(html));
                  }).then(function () {
                    if (options.verbose) log('Map generated: Processed/Albums/' + safeName + '/map.html (' + mapData.length + ' locations)');
                    return Promise.resolve();
                  });
                });
              });
            }).catch(function () { return Promise.resolve(); });
          }).catch(function () { return Promise.resolve(); });
        });
      });
      return seq;
    }).catch(function () { return Promise.resolve(); });
  }

  /** Global map: optional index + canonicalPathMap; if missing, skip or could scan Processed (not implemented here). */
  function runGlobalMap(processedHandle, options, log, optionalIndex, optionalCanonicalPathMap) {
    log = log || function () {};
    if (!options.generateMap) return Promise.resolve();
    var index = optionalIndex;
    var canonicalPathMap = optionalCanonicalPathMap || {};
    if (!index || !index.records) {
      log('Global map: skipped (no index/canonicalPathMap provided).');
      return Promise.resolve();
    }
    var loadMapDataPromise = processedHandle.getFileHandle('map_data.json').then(function (h) {
      return h.getFile().then(function (f) { return f.text(); }).then(function (t) {
        try { return JSON.parse(t); } catch (e) { return null; }
      });
    }).catch(function () { return null; });
    var mapData = [];
    index.records.forEach(function (rec) {
      var path = canonicalPathMap[rec.filename];
      if (!path) return;
      if (validGps(rec.json_gps)) {
        mapData.push({ lat: rec.json_gps.lat, lon: rec.json_gps.lon, path: path.replace(/\\/g, '/'), filename: rec.filename, taken_at: getSortableTime(rec) });
      }
    });
    var needExif = [];
    index.records.forEach(function (rec) {
      var path = canonicalPathMap[rec.filename];
      if (!path) return;
      if (validGps(rec.json_gps)) return;
      needExif.push({ path: path, filename: rec.filename, rec: rec });
    });
    var seq = Promise.resolve();
    needExif.forEach(function (entry) {
      seq = seq.then(function () {
        return getFileFromProcessedByPathLocal(processedHandle, entry.path).then(function (buf) {
          var gps = (typeof GphotoExif !== 'undefined' && GphotoExif.getExifGps) ? GphotoExif.getExifGps(buf) : null;
          if (validGps(gps)) {
            mapData.push({ lat: gps.lat, lon: gps.lon, path: entry.path.replace(/\\/g, '/'), filename: entry.filename, taken_at: getSortableTime(entry.rec) });
          }
          return Promise.resolve();
        }).catch(function () { return Promise.resolve(); });
      });
    });
    return seq.then(function () {
      return loadMapDataPromise.then(function (loadedMapData) {
        var mergedMapData = (loadedMapData && Array.isArray(loadedMapData) ? loadedMapData : []).concat(mapData);
        var byPath = {};
        mergedMapData.forEach(function (e) { byPath[e.path] = e; });
        mergedMapData = Object.keys(byPath).map(function (p) { return byPath[p]; });
        mergedMapData.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
        var mapDataJson = JSON.stringify(mergedMapData, null, 2);
        var mapHtml = buildMapHtmlString(mergedMapData);
        log('Map generated: Processed/map.html (' + mergedMapData.length + ' locations)');
        return writeFileToDirLocal(processedHandle, 'map_data.json', new TextEncoder().encode(mapDataJson))
          .then(function () { return writeFileToDirLocal(processedHandle, 'map.html', new TextEncoder().encode(mapHtml)); });
      });
    });
  }

  /** Per-year maps: list top-level dirs under Processed that look like YYYY, build map from files in each. */
  function runPerYearMaps(processedHandle, options, log, optionalIndex, optionalCanonicalPathMap) {
    log = log || function () {};
    if (!options.generateMap || !options.rebuildAlbums) return Promise.resolve();
    var index = optionalIndex;
    var canonicalPathMap = optionalCanonicalPathMap || {};
    if (!index || !index.records) return Promise.resolve();
    var pathBasename = function (pathStr) {
      var last = pathStr.lastIndexOf('/');
      return last < 0 ? pathStr : pathStr.slice(last + 1);
    };
    var yearsSet = {};
    Object.keys(canonicalPathMap).forEach(function (name) {
      var rel = canonicalPathMap[name];
      if (rel.indexOf('/') >= 0) yearsSet[rel.split('/')[0]] = true;
    });
    var yearsCreated = Object.keys(yearsSet).sort();
    var mapSeq = Promise.resolve();
    yearsCreated.forEach(function (year) {
      mapSeq = mapSeq.then(function () {
        var entries = [];
        index.records.forEach(function (rec) {
          var path = canonicalPathMap[rec.filename];
          if (!path || path.indexOf(year + '/') !== 0) return;
          entries.push({ path: pathBasename(path), filename: rec.filename, rec: rec });
        });
        if (entries.length === 0) return Promise.resolve();
        return buildMapDataForEntries(entries, '', getFileFromProcessedByPathLocal, processedHandle).then(function (data) {
          if (data.length === 0) return Promise.resolve();
          return getOrCreatePathLocal(processedHandle, year).then(function (yearDir) {
            var json = JSON.stringify(data, null, 2);
            var html = buildMapHtmlString(data);
            return writeFileToDirLocal(yearDir, 'map_data.json', new TextEncoder().encode(json))
              .then(function () { return writeFileToDirLocal(yearDir, 'map.html', new TextEncoder().encode(html)); });
          }).then(function () {
            if (options.verbose) log('Map generated: Processed/' + year + '/map.html (' + data.length + ' locations)');
            return Promise.resolve();
          });
        });
      });
    });
    return mapSeq;
  }

  g.GphotoPipelinePhases.runMapGeneration = function (processedHandle, options, log, optionalIndex, optionalCanonicalPathMap) {
    log = log || function () {};
    return runGlobalMap(processedHandle, options, log, optionalIndex, optionalCanonicalPathMap).then(function () {
      return runPerYearMaps(processedHandle, options, log, optionalIndex, optionalCanonicalPathMap);
    }).then(function () {
      return runPerAlbumMapsFromManifests(processedHandle, options, log);
    });
  };
  g.GphotoPipelinePhases.runGlobalMap = runGlobalMap;
  g.GphotoPipelinePhases.runPerYearMaps = runPerYearMaps;
  g.GphotoPipelinePhases.runPerAlbumMapsFromManifests = runPerAlbumMapsFromManifests;

  g.GphotoPipelinePhases.runAlbumsAndMaps = function (ctx, log) {
    log = log || function () {};
    var outputHandle = ctx.outputHandle;
    var fileMap = ctx.fileMap;
    var mergeResult = ctx.mergeResult;
    var options = ctx.options || {};
    var dryRun = options.dryRun === true;
    var report = mergeResult.report;
    var canonicalPathMap = mergeResult.canonicalPathMap || {};
    var index = mergeResult.index;
    var processedHandle = mergeResult.processedHandle;
    var loadedSummary = ctx.loadedSummary;
    var getOrCreatePath = ctx.getOrCreatePath;
    var writeFileToDir = ctx.writeFileToDir;
    var safeErrorPath = ctx.safeErrorPath;
    var pathBasename = ctx.pathBasename;

    function getFileFromProcessedByPath(procHandle, relativePath) {
      var sanitizeNameForFS = ctx.sanitizeNameForFS;
      var parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
      if (parts.length === 0) return Promise.reject(new Error('empty path'));
      if (parts.length === 1) {
        var safe0 = sanitizeNameForFS(parts[0]);
        return procHandle.getFileHandle(safe0).then(function (h) { return h.getFile(); }).then(function (f) { return f.arrayBuffer(); });
      }
      var dirPath = parts.slice(0, -1).join('/');
      var fileName = sanitizeNameForFS(parts[parts.length - 1]);
      return getOrCreatePath(procHandle, dirPath).then(function (dir) {
        return dir.getFileHandle(fileName).then(function (h) { return h.getFile(); }).then(function (f) { return f.arrayBuffer(); });
      });
    }

    function findRecordById(recordId) {
      if (!index || !index.records) return null;
      for (var r = 0; r < index.records.length; r++) {
        if (index.records[r].id === recordId) return index.records[r];
      }
      return null;
    }
    function buildAlbums() {
      if (!index || !index.records || options.rebuildAlbums === false) return Promise.resolve({ albumsCreated: [], yearsCreated: [], albumToFiles: {} });
      var albumToFiles = {};
      var yearsSet = {};
      index.records.forEach(function (rec) {
        var album = (rec.albums && rec.albums[0]) || '';
        if (!album) return;
        if (isYearLikeAlbumName(album)) return;
        var canonical = canonicalPathMap[rec.filename];
        var useFilename = rec.filename;
        if (!canonical && rec.is_duplicate && rec.duplicate_of != null) {
          var canonicalRec = findRecordById(rec.duplicate_of);
          if (canonicalRec) {
            canonical = canonicalPathMap[canonicalRec.filename];
            if (canonical) useFilename = canonicalRec.filename;
          }
        }
        if (!canonical) return;
        if (!albumToFiles[album]) albumToFiles[album] = [];
        albumToFiles[album].push({ filename: useFilename, canonicalPath: canonical });
      });
      Object.keys(canonicalPathMap).forEach(function (name) {
        var rel = canonicalPathMap[name];
        if (rel.indexOf('/') >= 0) yearsSet[rel.split('/')[0]] = true;
      });
      var yearsCreated = Object.keys(yearsSet).sort();
      var albumsCreated = [];
      var seq = Promise.resolve();
      /* When "Create Google albums maps (uncheck to copy duplicates as well)" is ON (checked), do NOT copy files = no duplicates. When OFF (unchecked), copy files = include duplicates. */
      var duplicateFiles = options.albumsDuplicateFiles !== true;
      Object.keys(albumToFiles).forEach(function (albumName) {
        var files = albumToFiles[albumName];
        var safeName = (ctx.sanitizeNameForFS && ctx.sanitizeNameForFS(albumName)) || 'Unnamed';
        seq = seq.then(function () {
          return getOrCreatePath(processedHandle, 'Albums/' + safeName);
        }).then(function (albumDir) {
          albumsCreated.push('Albums/' + safeName);
          var newPaths = files.map(function (entry) { return '../../' + entry.canonicalPath.replace(/\\/g, '/'); });
          return albumDir.getFileHandle('manifest.json').then(function (h) {
            return h.getFile().then(function (f) { return f.text(); });
          }).then(function (text) {
            var existing = [];
            try {
              var parsed = JSON.parse(text);
              if (parsed && Array.isArray(parsed.canonical_paths)) existing = parsed.canonical_paths;
            } catch (e) {}
            var pathSet = {};
            existing.forEach(function (p) { pathSet[p] = true; });
            newPaths.forEach(function (p) { pathSet[p] = true; });
            return Object.keys(pathSet);
          }).catch(function () {
            return newPaths;
          }).then(function (manifestPaths) {
            if (duplicateFiles) {
              var copySeq = Promise.resolve();
              files.forEach(function (entry) {
                copySeq = copySeq.then(function () {
                  return getFileFromProcessedByPath(processedHandle, entry.canonicalPath).then(function (buf) {
                    return writeFileToDir(albumDir, entry.filename, buf);
                  }).catch(function (err) {
                    var msg = err && err.message ? err.message : String(err);
                    report.errors.push('album copy ' + safeErrorPath(entry.filename) + ': ' + msg);
                    if (!report.problematic_files) report.problematic_files = [];
                    report.problematic_files.push({ file: safeErrorPath(entry.filename), reason: msg });
                    return Promise.resolve();
                  });
                });
              });
              return copySeq.then(function () {
                var manifest = { canonical_paths: manifestPaths, album_name: albumName };
                return writeFileToDir(albumDir, 'manifest.json', new TextEncoder().encode(JSON.stringify(manifest, null, 2)));
              });
            }
            var manifest = { canonical_paths: manifestPaths, album_name: albumName };
            return writeFileToDir(albumDir, 'manifest.json', new TextEncoder().encode(JSON.stringify(manifest, null, 2)));
          });
        });
      });
      return seq.then(function () { return { albumsCreated: albumsCreated, yearsCreated: yearsCreated, albumToFiles: albumToFiles }; })
      .catch(function () { return { albumsCreated: albumsCreated, yearsCreated: yearsCreated, albumToFiles: albumToFiles }; });
    }

    if (dryRun) return Promise.resolve();
    var totalProcessed = (report.merged || []).length + (report.transferred || []).length + (report.exif_restored || []).length;
    var jsonMatched = (report.merged || []).length + (report.exif_restored || []).length;
    var missingJson = totalProcessed - jsonMatched;
    var generatedAt = new Date().toISOString();
    var extensionVersion = options.extensionVersion || 'Dev';
    var zipsExtractedSoFar = (loadedSummary && loadedSummary.zips_extracted_so_far) ? loadedSummary.zips_extracted_so_far.slice() : [];
    for (var fmKey in fileMap) {
      if (Object.prototype.hasOwnProperty.call(fileMap, fmKey) && fileMap[fmKey].from_zip) {
        var zipName = fileMap[fmKey].from_zip;
        if (zipsExtractedSoFar.indexOf(zipName) === -1) zipsExtractedSoFar.push(zipName);
      }
    }
    var summaryReport = {
      generated_at: generatedAt,
      extension_version: extensionVersion,
      total_media_processed: totalProcessed,
      total_images_in_processed: totalProcessed,
      json_matched: jsonMatched,
      missing_json: missingJson,
      orphan_media: totalProcessed - jsonMatched,
      exif_restored: (report.exif_restored || []).length,
      albums_rebuilt: 0,
      duplicates_detected: (index && index.stats) ? (index.stats.exact_duplicate_count || 0) : 0,
      total_media_in_lake: (index && index.stats) ? (index.stats.total_media_files || 0) : 0,
      total_json_in_lake: (index && index.stats) ? (index.stats.total_json_files || 0) : 0,
      processed_by_year: [],
      albums_created: [],
      zips_extracted_so_far: zipsExtractedSoFar,
      problematic_files_count: (report.problematic_files && report.problematic_files.length) || 0,
      extraction_zip_entries_total: (report.extraction_summary && report.extraction_summary.zip_entries_total) != null ? report.extraction_summary.zip_entries_total : (report.extraction_zip_entries_total || 0),
      extraction_zip_entries_images: (report.extraction_summary && report.extraction_summary.zip_entries_images) != null ? report.extraction_summary.zip_entries_images : (report.extraction_zip_entries_images || 0),
      extraction_zip_entries_videos: (report.extraction_summary && report.extraction_summary.zip_entries_videos) != null ? report.extraction_summary.zip_entries_videos : (report.extraction_zip_entries_videos || 0),
      extraction_zip_entries_other: (report.extraction_summary && report.extraction_summary.zip_entries_other) != null ? report.extraction_summary.zip_entries_other : (report.extraction_zip_entries_other || 0),
      extraction_entries_ok: (report.extraction_summary && report.extraction_summary.entries_ok) != null ? report.extraction_summary.entries_ok : 0,
      extraction_entries_failed: (report.extraction_summary && report.extraction_summary.entries_failed) != null ? report.extraction_summary.entries_failed : 0,
      merge_merged_count: (report.merged && report.merged.length) || 0,
      merge_transferred_count: (report.transferred && report.transferred.length) || 0,
      merge_skipped_count: (report.skipped_existing && report.skipped_existing.length) || 0,
      not_merged_count: (report.transferred && report.transferred.length) || 0,
      remerged_count: (report.remerged && report.remerged.length) || 0
    };
    if (loadedSummary && typeof loadedSummary === 'object') {
      summaryReport.not_merged_count = (Number(loadedSummary.not_merged_count) || 0) - (report.remerged && report.remerged.length || 0) + (report.transferred && report.transferred.length || 0);
      summaryReport.total_media_processed = (Number(loadedSummary.total_media_processed) || 0) + totalProcessed;
      summaryReport.total_images_in_processed = summaryReport.total_media_processed;
      summaryReport.json_matched = (Number(loadedSummary.json_matched) || 0) + jsonMatched;
      summaryReport.missing_json = (Number(loadedSummary.missing_json) || 0) + missingJson;
      summaryReport.orphan_media = summaryReport.missing_json;
      summaryReport.exif_restored = (Number(loadedSummary.exif_restored) || 0) + summaryReport.exif_restored;
      summaryReport.duplicates_detected = (index && index.stats) ? (index.stats.exact_duplicate_count || 0) : (Number(loadedSummary.duplicates_detected) || 0);
    }
    var chain = buildAlbums().then(function (albumResult) {
      summaryReport.albums_rebuilt = (albumResult.albumsCreated || []).length;
      summaryReport.processed_by_year = albumResult.yearsCreated || [];
      summaryReport.albums_created = albumResult.albumsCreated || [];
      var summaryJson = JSON.stringify(summaryReport, null, 2);
      var summaryLines = [
        'Summary Report',
        'Generated: ' + generatedAt + ' | Extension: ' + extensionVersion,
        '',
        'Broad view (cumulative across runs)',
        'Total images in Processed: ' + summaryReport.total_images_in_processed,
        'Orphan media (no JSON): ' + summaryReport.orphan_media,
        'Matched (JSON applied) so far: ' + summaryReport.json_matched,
        'EXIF restored: ' + summaryReport.exif_restored,
        'Zips already extracted (so far): ' + (summaryReport.zips_extracted_so_far.length ? summaryReport.zips_extracted_so_far.join(', ') : 'none'),
        '',
        'Lake (current run)',
        'Total media in Lake: ' + summaryReport.total_media_in_lake,
        'Total JSON in Lake: ' + summaryReport.total_json_in_lake,
        'Duplicates detected: ' + summaryReport.duplicates_detected,
        '',
        'Albums rebuilt: ' + summaryReport.albums_rebuilt,
        'Processed/YYYY created: ' + (summaryReport.processed_by_year.length ? summaryReport.processed_by_year.join(', ') : '(flat)'),
        'Albums created: ' + (summaryReport.albums_created.length ? summaryReport.albums_created.join(', ') : 'none'),
        '',
        'Problematic / skipped: ' + (summaryReport.problematic_files_count || 0) + ' file(s)',
        '',
        'Extraction: ' + (summaryReport.extraction_zip_entries_total || 0) + ' in zips (' + (summaryReport.extraction_zip_entries_images || 0) + ' images, ' + (summaryReport.extraction_zip_entries_videos || 0) + ' videos, ' + (summaryReport.extraction_zip_entries_other || 0) + ' other); extracted ' + (summaryReport.extraction_entries_ok || 0) + ' ok, ' + (summaryReport.extraction_entries_failed || 0) + ' failed.',
        'Merge: ' + (summaryReport.merge_merged_count || 0) + ' merged, ' + (summaryReport.merge_transferred_count || 0) + ' transferred, ' + (summaryReport.merge_skipped_count || 0) + ' skipped.',
        'Not merged (no JSON yet): ' + (summaryReport.not_merged_count || 0) + ' (re-merge on next run if JSON appears).',
        'Re-merged this run: ' + (summaryReport.remerged_count || 0) + '.'
      ];
      var summaryTxt = summaryLines.join('\n');
      log('--- Summary Report ---');
      log('Total media files processed: ' + summaryReport.total_media_processed);
      log('JSON matched: ' + summaryReport.json_matched);
      log('Missing JSON: ' + summaryReport.missing_json);
      log('EXIF restored: ' + summaryReport.exif_restored);
      log('Albums rebuilt: ' + summaryReport.albums_rebuilt);
      log('Duplicates detected: ' + summaryReport.duplicates_detected);
      if ((summaryReport.problematic_files_count || 0) > 0) log('Problematic/skipped files: ' + summaryReport.problematic_files_count);
      log('Extraction: ' + (summaryReport.extraction_zip_entries_total || 0) + ' in zips (' + (summaryReport.extraction_zip_entries_images || 0) + ' images, ' + (summaryReport.extraction_zip_entries_videos || 0) + ' videos, ' + (summaryReport.extraction_zip_entries_other || 0) + ' other); extracted ' + (summaryReport.extraction_entries_ok || 0) + ' ok, ' + (summaryReport.extraction_entries_failed || 0) + ' failed.');
      log('Merge: ' + (summaryReport.merge_merged_count || 0) + ' merged, ' + (summaryReport.merge_transferred_count || 0) + ' transferred, ' + (summaryReport.merge_skipped_count || 0) + ' skipped.');
      return writeFileToDir(outputHandle, 'summary_report.json', new TextEncoder().encode(summaryJson)).then(function () {
        return writeFileToDir(outputHandle, 'summary_report.txt', new TextEncoder().encode(summaryTxt));
      }).then(function () {
        var recByFilename = {};
        if (index && index.records) {
          index.records.forEach(function (r) { recByFilename[r.filename] = r; });
        }
        var currentRunRows = [];
        Object.keys(canonicalPathMap).forEach(function (name) {
          var path = canonicalPathMap[name].replace(/\\/g, '/');
          var rec = recByFilename[name];
          var taken_at = rec ? getSortableTime(rec) : null;
          if (taken_at === 0) taken_at = null;
          var lat = null, lon = null;
          if (rec && validGps(rec.json_gps)) { lat = rec.json_gps.lat; lon = rec.json_gps.lon; }
          else if (rec && validGps(rec.exif_gps)) { lat = rec.exif_gps.lat; lon = rec.exif_gps.lon; }
          currentRunRows.push({ filename: name, path: path, taken_at: taken_at, lat: lat, lon: lon });
        });
        return outputHandle.getFileHandle('media_db.json').then(function (h) {
          return h.getFile().then(function (f) { return f.text(); });
        }).then(function (text) {
          var existing = [];
          try {
            var parsed = JSON.parse(text);
            if (Array.isArray(parsed)) existing = parsed;
          } catch (e) {}
          var byPath = {};
          existing.forEach(function (e) { byPath[e.path] = e; });
          currentRunRows.forEach(function (e) { byPath[e.path] = e; });
          var merged = Object.keys(byPath).map(function (p) { return byPath[p]; });
          merged.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
          return writeFileToDir(outputHandle, 'media_db.json', new TextEncoder().encode(JSON.stringify(merged, null, 2)));
        }).catch(function () {
          var merged = currentRunRows.slice();
          merged.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
          return writeFileToDir(outputHandle, 'media_db.json', new TextEncoder().encode(JSON.stringify(merged, null, 2)));
        }).then(function () {
          log('Media DB written: media_db.json');
        });
      }).then(function () {
        if (!options.generateMap || !index || !processedHandle) {
          return { report: report, summaryReport: summaryReport };
        }
        var loadMapDataPromise = processedHandle.getFileHandle('map_data.json').then(function (h) {
          return h.getFile().then(function (f) { return f.text(); }).then(function (t) {
            try { return JSON.parse(t); } catch (e) { return null; }
          });
        }).catch(function () { return null; });
        return loadMapDataPromise.then(function (loadedMapData) {
          var mapData = [];
          index.records.forEach(function (rec) {
            var path = canonicalPathMap[rec.filename];
            if (!path) return;
            if (validGps(rec.json_gps)) {
              mapData.push({ lat: rec.json_gps.lat, lon: rec.json_gps.lon, path: path.replace(/\\/g, '/'), filename: rec.filename, taken_at: getSortableTime(rec) });
            }
          });
          var needExif = [];
          index.records.forEach(function (rec) {
            var path = canonicalPathMap[rec.filename];
            if (!path) return;
            if (validGps(rec.json_gps)) return;
            needExif.push({ path: path, filename: rec.filename, rec: rec });
          });
          var seq = Promise.resolve();
          needExif.forEach(function (entry) {
            seq = seq.then(function () {
              return getFileFromProcessedByPath(processedHandle, entry.path).then(function (buf) {
                var gps = (typeof GphotoExif !== 'undefined' && GphotoExif.getExifGps) ? GphotoExif.getExifGps(buf) : null;
                if (validGps(gps)) {
                  mapData.push({ lat: gps.lat, lon: gps.lon, path: entry.path.replace(/\\/g, '/'), filename: entry.filename, taken_at: getSortableTime(entry.rec) });
                }
                return Promise.resolve();
              }).catch(function () { return Promise.resolve(); });
            });
          });
          return seq.then(function () {
            var mergedMapData = (loadedMapData && Array.isArray(loadedMapData) ? loadedMapData : []).concat(mapData);
            var byPath = {};
            mergedMapData.forEach(function (e) { byPath[e.path] = e; });
            mergedMapData = Object.keys(byPath).map(function (p) { return byPath[p]; });
            mergedMapData.sort(function (a, b) { return (a.taken_at || 0) - (b.taken_at || 0); });
            var mapDataJson = JSON.stringify(mergedMapData, null, 2);
            var mapHtml = buildMapHtmlString(mergedMapData);
            log('Map generated: Processed/map.html (' + mergedMapData.length + ' locations)');
            return writeFileToDir(processedHandle, 'map_data.json', new TextEncoder().encode(mapDataJson))
              .then(function () { return writeFileToDir(processedHandle, 'map.html', new TextEncoder().encode(mapHtml)); })
              .then(function () {
                var result = { report: report, summaryReport: summaryReport };
                if (!options.rebuildAlbums) return result;
                var yearsCreated = albumResult.yearsCreated || [];
                var mapSeq = Promise.resolve();
                yearsCreated.forEach(function (year) {
                  mapSeq = mapSeq.then(function () {
                    var entries = [];
                    index.records.forEach(function (rec) {
                      var path = canonicalPathMap[rec.filename];
                      if (!path || path.indexOf(year + '/') !== 0) return;
                      entries.push({ path: pathBasename(path), filename: rec.filename, rec: rec });
                    });
                    if (entries.length === 0) return Promise.resolve();
                    return buildMapDataForEntries(entries, '', getFileFromProcessedByPath, processedHandle).then(function (data) {
                      if (data.length === 0) return Promise.resolve();
                      return getOrCreatePath(processedHandle, year).then(function (yearDir) {
                        var json = JSON.stringify(data, null, 2);
                        var html = buildMapHtmlString(data);
                        return writeFileToDir(yearDir, 'map_data.json', new TextEncoder().encode(json))
                          .then(function () { return writeFileToDir(yearDir, 'map.html', new TextEncoder().encode(html)); });
                      }).then(function () {
                        if (options.verbose) log('Map generated: Processed/' + year + '/map.html (' + data.length + ' locations)');
                        return Promise.resolve();
                      });
                    });
                  });
                });
                return mapSeq.then(function () {
                  return runPerAlbumMapsFromManifests(processedHandle, options, log).then(function () { return result; });
                });
              });
          });
        });
      });
    });
    return chain.catch(function () {
      report.errors.push('Summary, map, or album files could not be written. Output folder may have been moved, deleted, or permission was revoked.');
      if (summaryReport) summaryReport.summary_report_not_written = true;
      return { report: report, summaryReport: summaryReport };
    });
  };
})();
