/**
 * Pipeline: scan (dry run) and apply (full run) for Google Takeout.
 * Uses File System Access API: input/output DirectoryHandles.
 * ZIP via GphotoZip (fflate), EXIF via GphotoExif.
 */
(function (global) {
  'use strict';

  var IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'];
  var VIDEO_EXT = ['.mp4', '.mov', '.avi', '.mkv', '.m4v', '.flv', '.wmv', '.webm'];
  var META_SUFFIX = '.json';
  var SUPPLEMENTAL = '.supplemental-metadata.json';
  var SUPPLEMENTAL_NO_JSON = '.supplemental-metadata';

  function ext(name) {
    var i = name.lastIndexOf('.');
    return i >= 0 ? name.substring(i).toLowerCase() : '';
  }

  /** Stem = characters up to the first period (.). Match media and metadata by stem. */
  function stem(name) {
    var i = name.indexOf('.');
    return i >= 0 ? name.slice(0, i) : name;
  }

  function baseWithoutMeta(name) {
    if (name.endsWith(SUPPLEMENTAL)) return name.slice(0, -SUPPLEMENTAL.length);
    if (name.endsWith(SUPPLEMENTAL_NO_JSON)) return name.slice(0, -SUPPLEMENTAL_NO_JSON.length);
    if (name.endsWith(META_SUFFIX)) return name.slice(0, -META_SUFFIX.length);
    return name;
  }

  /** Filename without extension (e.g. "IMG_123" from "IMG_123.jpg") for twin matching. */
  function baseNameNoExt(name) {
    var dot = name.lastIndexOf('.');
    return dot >= 0 ? name.slice(0, dot) : name;
  }

  /** Normalize base name for index lookup (match media-index.js). */
  function normalizeBaseName(filename) {
    var base = baseNameNoExt(filename);
    base = base.replace(/\s*\(\d+\)\s*$/i, '').replace(/-edited\s*$/i, '').trim();
    return base.toLowerCase();
  }

  /** Return true if EXIF and JSON timestamps represent the same time (within 1 second). */
  function timestampsMatch(exifTs, jsonTs) {
    if (!exifTs && !jsonTs) return true;
    if (!exifTs || !jsonTs) return false;
    var exifMs = parseExifTimestampToMs(exifTs);
    var jsonMs = parseJsonTimestampToMs(jsonTs);
    if (exifMs == null || jsonMs == null) return false;
    return Math.abs(exifMs - jsonMs) < 1000;
  }
  function parseExifTimestampToMs(ts) {
    if (!ts || typeof ts !== 'string') return null;
    /* EXIF format "YYYY:MM:DD HH:MM:SS" */
    var m = ts.match(/^(\d{4}):(\d{2}):(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
    if (m) {
      var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
      return isNaN(d.getTime()) ? null : d.getTime();
    }
    return null;
  }
  function parseJsonTimestampToMs(ts) {
    if (ts == null) return null;
    var n = Number(ts);
    if (!isNaN(n)) return n >= 1e12 ? n : n * 1000;
    if (typeof ts === 'string') {
      var parsed = Date.parse(ts);
      return isNaN(parsed) ? null : parsed;
    }
    return null;
  }

  /** Get 4-digit year from index record: exif_timestamp or json_timestamp, then fallback to parent folder (e.g. "Photos from 2007"). */
  function yearFromRecord(rec) {
    if (!rec) return null;
    var ts = rec.exif_timestamp || rec.json_timestamp || null;
    if (ts) {
      var ms = parseExifTimestampToMs(ts) || parseJsonTimestampToMs(ts);
      if (ms != null) {
        var y = new Date(ms).getFullYear();
        if (y >= 1900 && y <= 2100) return String(y);
      }
    }
    var parent = (rec.albums && rec.albums[0]) || '';
    if (parent) {
      var m = parent.match(/^Photos from (\d{4})$/i) || parent.match(/^(\d{4})$/);
      if (m) {
        var yr = parseInt(m[1], 10);
        if (yr >= 1900 && yr <= 2100) return String(yr);
      }
    }
    return null;
  }

  function isImage(name) {
    return IMAGE_EXT.indexOf(ext(name)) >= 0;
  }
  function isVideo(name) {
    return VIDEO_EXT.indexOf(ext(name)) >= 0;
  }

  /**
   * Read a File into an ArrayBuffer via streaming (avoids arrayBuffer() on large files
   * which can fail with "permission problems" or "Array buffer allocation failed").
   */
  function readFileToBuffer(file) {
    var size = Number(file.size) || 0;
    if (size === 0) {
      return Promise.resolve(new ArrayBuffer(0));
    }
    var reader = file.stream().getReader();
    var out = new Uint8Array(size);
    var offset = 0;
    function pump() {
      return reader.read().then(function (result) {
        if (result.done) {
          return out.buffer;
        }
        var chunk = result.value;
        var len = chunk.byteLength || chunk.length;
        if (len > 0) {
          out.set(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk), offset);
          offset += len;
        }
        return pump();
      });
    }
    return pump();
  }

  /** List one level only (no recursion). Returns [{ name, handle, kind }]. */
  function listTopLevel(handle) {
    var it;
    try {
      it = handle.entries();
    } catch (e) {
      return Promise.reject(e);
    }
    var list = [];
    function drain() {
      var n = it.next();
      var prom = n && typeof n.then === 'function' ? n : Promise.resolve(n);
      return prom.then(function (r) {
        if (r.done) return list;
        list.push({ name: r.value[0], handle: r.value[1], kind: r.value[1].kind });
        return drain();
      });
    }
    return drain();
  }

  /** Fully consume the iterator at this level before recursing, so we never hold
   *  a parent iterator open across async work (avoids "state cached in an interface object"). */
  function listDir(handle, path, log) {
    path = path || '';
    function collectLevel(h, p) {
      var it;
      try {
        it = h.entries();
      } catch (e) {
        return Promise.reject(e);
      }
      if (typeof it.next !== 'function') {
        return Promise.reject(new Error('entries() did not return an iterator'));
      }
      var levelList = [];
      function drain() {
        var n = it.next();
        var prom = n && typeof n.then === 'function' ? n : Promise.resolve(n);
        return prom.then(function (r) {
          if (r.done) return levelList;
          levelList.push({ name: r.value[0], handle: r.value[1], full: p ? p + '/' + r.value[0] : r.value[0] });
          return drain();
        });
      }
      return drain().then(function (list) {
        var results = [];
        var promises = [];
        for (var i = 0; i < list.length; i++) {
          var name = list[i].name;
          var entryHandle = list[i].handle;
          var full = list[i].full;
          if (entryHandle.kind === 'directory') {
            promises.push(collectLevel(entryHandle, full).then(function (sub) {
              results.push.apply(results, sub);
            }));
          } else {
            results.push({ name: name, full: full, handle: entryHandle });
          }
        }
        return Promise.all(promises).then(function () { return results; });
      });
    }
    return collectLevel(handle, path);
  }

  /**
   * Match media to metadata by stem: the sequence of characters up to the first period (.).
   * If a media file has stem S and a metadata file has stem S, they match.
   */
  function matchMediaToMeta(files) {
    var media = {};
    var meta = {};
    var byBase = {};
    var isMetaFile = function (name) {
      return name.endsWith(SUPPLEMENTAL) || name.endsWith(SUPPLEMENTAL_NO_JSON) || name.endsWith('.json');
    };
    var isMediaFile = function (name) {
      var e = ext(name);
      return IMAGE_EXT.indexOf(e) >= 0 || VIDEO_EXT.indexOf(e) >= 0 || name.indexOf('.') < 0;
    };
    files.forEach(function (f) {
      var name = f.name;
      var s = stem(name);
      if (isMediaFile(name)) {
        media[name] = f;
        if (!byBase[s]) byBase[s] = {};
        /* Prefer media with image extension when multiple share the same stem (for EXIF merge). */
        var current = byBase[s].media;
        if (!current) {
          byBase[s].media = f;
        } else if (IMAGE_EXT.indexOf(ext(name)) >= 0 && IMAGE_EXT.indexOf(ext(current.name)) < 0) {
          byBase[s].media = f;
        }
      } else if (isMetaFile(name)) {
        if (!byBase[s]) byBase[s] = {};
        byBase[s].meta = f;
      }
    });
    return { media: media, byBase: byBase, files: files };
  }

  function scan(inputHandle, options, log) {
    log = log || function () {};
    return listDir(inputHandle, '', log).then(function (files) {
      var zipFiles = files.filter(function (f) {
        return f.name.toLowerCase().endsWith('.zip');
      });
      var nonZip = files.filter(function (f) {
        return !f.name.toLowerCase().endsWith('.zip');
      });
      var matched = matchMediaToMeta(nonZip);
      var report = {
        zip_count: zipFiles.length,
        files_scanned: files.length,
        byBase: matched.byBase,
        media: matched.media,
        parent_folders: []
      };
      var bases = Object.keys(matched.byBase);
      report.matched_images = bases.filter(function (b) {
        return matched.byBase[b].media && matched.byBase[b].meta && isImage(matched.byBase[b].media.name);
      }).length;
      report.matched_videos = bases.filter(function (b) {
        return matched.byBase[b].media && isVideo(matched.byBase[b].media.name);
      }).length;
      report.transfer_only = bases.filter(function (b) {
        return matched.byBase[b].media && !matched.byBase[b].meta;
      }).length;
      log('Scan complete. Zips: ' + report.zip_count + ', matched images: ' + report.matched_images + ', matched videos: ' + report.matched_videos + ', transfer-only: ' + report.transfer_only + '.');
      return report;
    });
  }

  function readJsonFile(fileHandle) {
    return fileHandle.getFile().then(function (f) {
      return f.text();
    }).then(function (t) {
      return JSON.parse(t);
    });
  }

  /**
   * Resolve lake file name; avoid collisions with current run and optionally with existing lake.
   * @param {Object} collisionCounter - mutable counter per baseName
   * @param {string} baseName - filename (e.g. IMG_1.jpg)
   * @param {Object} [takenSet] - optional set of names already in use (existing lake + this run); mutated to add chosen name
   */
  function resolveLakeName(collisionCounter, baseName, takenSet) {
    for (;;) {
    var n = collisionCounter[baseName];
      var name;
    if (n === undefined) {
      collisionCounter[baseName] = 0;
        name = baseName;
      } else {
    collisionCounter[baseName] = n + 1;
    var dot = baseName.lastIndexOf('.');
    var base = dot >= 0 ? baseName.slice(0, dot) : baseName;
    var ext = dot >= 0 ? baseName.slice(dot) : '';
        name = base + '_' + collisionCounter[baseName] + ext;
      }
      if (!takenSet || !takenSet[name]) {
        if (takenSet) takenSet[name] = true;
        return name;
      }
    }
  }

  function parentFolderFromPath(pathStr) {
    var last = pathStr.lastIndexOf('/');
    if (last <= 0) return '';
    var dir = pathStr.slice(0, last);
    var parts = dir.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : dir;
  }

  /** Sanitize name for File System Access API: disallow \\ / : * ? " < > | and control chars; truncate; keep ASCII-only to avoid "Name is not allowed" for corrupt zip entries. */
  var FS_NAME_MAX_LEN = 200;
  function sanitizeNameForFS(name) {
    if (name == null || typeof name !== 'string') return '_';
    var s = name.replace(/[\x00-\x1f\\/:*?"<>|]/g, '_').replace(/[^\x20-\x7e]/g, '_').trim();
    if (s.length === 0) return '_';
    return s.length > FS_NAME_MAX_LEN ? s.slice(0, FS_NAME_MAX_LEN) : s;
  }

  /** Safe string for error messages: printable ASCII only, bounded length (no binary/garbage). */
  function safeErrorPath(str) {
    if (str == null) return '';
    if (typeof str !== 'string') {
      try { str = String(str); } catch (e) { return '?'; }
    }
    var out = '';
    var max = 200;
    for (var i = 0; i < str.length && out.length < max; i++) {
      var c = str.charCodeAt(i);
      if (c >= 32 && c <= 126 && c !== 92) out += str[i];
    }
    return out.length ? out : '?';
  }

  /** True if string looks like a corrupt/binary zip entry name (avoid showing in errors). */
  function isCorruptZipEntryName(str) {
    if (!str || typeof str !== 'string') return true;
    var bad = 0;
    var len = Math.min(str.length, 300);
    for (var i = 0; i < len; i++) {
      var c = str.charCodeAt(i);
      if (c < 32 || c > 126) bad++;
    }
    return bad > len / 4;
  }

  function apply(inputHandle, outputHandle, options, log) {
    log = log || function () {};
    var dryRun = options.dryRun === true;
    var fileMap = {};
    var collisionCounter = {};
    var report = { merged: [], transferred: [], errors: [], moved_from_lake: [], exif_restored: [], exif_replaced: [], skipped_existing: [], problematic_files: [], dry_run: dryRun, timestamp: new Date().toISOString(), generated_at: new Date().toISOString(), extension_version: options.extensionVersion || 'Dev' };
    var canonicalPathMap = {};

    function ensureDir(parent, name) {
      var safe = sanitizeNameForFS(name);
      return parent.getDirectoryHandle(safe, { create: true }).catch(function () {
        return parent.getDirectoryHandle(safe);
      });
    }

    /** Get or create a directory by path (e.g. "a/b/c" under root). */
    function getOrCreatePath(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 ensureDir(h, part); });
        })(parts[i]);
      }
      return p;
    }

    function pathDirname(pathStr) {
      var last = pathStr.lastIndexOf('/');
      return last <= 0 ? '' : pathStr.slice(0, last);
    }
    function pathBasename(pathStr) {
      var last = pathStr.lastIndexOf('/');
      return last < 0 ? pathStr : pathStr.slice(last + 1);
    }

    function writeFileToDir(dirHandle, name, blobOrBuffer) {
      if (dryRun) return Promise.resolve();
      var safe = sanitizeNameForFS(name);
      return dirHandle.getFileHandle(safe, { create: true }).then(function (h) {
        return h.createWritable();
      }).then(function (w) {
        /* Write ArrayBuffer directly so binary (EXIF) is preserved; Blob can behave differently in some contexts. */
        var p = w.write(blobOrBuffer instanceof ArrayBuffer ? blobOrBuffer : blobOrBuffer);
        return (p && typeof p.then === 'function' ? p : Promise.resolve()).then(function () {
          return w.close();
        });
      });
    }

    return outputHandle.getDirectoryHandle('lake', { create: true }).then(function (lakeHandle) {
      return outputHandle.getDirectoryHandle('Processed', { create: true }).then(function (processedHandle) {
        var existingLakePromise = listTopLevel(lakeHandle).then(function (entries) {
          var takenSet = {};
          for (var i = 0; i < entries.length; i++) {
            if (entries[i].kind === 'file') takenSet[entries[i].name] = true;
          }
          return takenSet;
        });
        var fileMapLoadPromise = outputHandle.getFileHandle('file_map.json').then(function (h) {
          return h.getFile().then(function (f) { return f.text(); }).then(function (t) {
            try { return JSON.parse(t); } catch (e) { return {}; }
          });
        }).catch(function () { return {}; });
        var reportLoadPromise = outputHandle.getFileHandle('report.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 Promise.all([existingLakePromise, fileMapLoadPromise, reportLoadPromise]).then(function (results) {
          var takenSet = results[0];
          var loadedFileMap = results[1];
          var loadedReport = results[2];
          if (loadedFileMap && typeof loadedFileMap === 'object') {
            for (var k in loadedFileMap) {
              if (Object.prototype.hasOwnProperty.call(loadedFileMap, k)) fileMap[k] = loadedFileMap[k];
            }
          }
          var notMerged = (loadedReport && (loadedReport.not_merged || loadedReport.transferred)) || [];
          if (!Array.isArray(notMerged)) notMerged = [];
          var ctx = {
            inputHandle: inputHandle,
            outputHandle: outputHandle,
            lakeHandle: lakeHandle,
            processedHandle: processedHandle,
            fileMap: fileMap,
            not_merged: notMerged,
            collisionCounter: collisionCounter,
            takenSet: takenSet,
            report: report,
            options: options,
            canonicalPathMap: canonicalPathMap,
            getOrCreatePath: getOrCreatePath,
            ensureDir: ensureDir,
            writeFileToDir: writeFileToDir,
            listDir: listDir,
            listTopLevel: listTopLevel,
            resolveLakeName: resolveLakeName,
            pathBasename: pathBasename,
            pathDirname: pathDirname,
            sanitizeNameForFS: sanitizeNameForFS,
            safeErrorPath: safeErrorPath,
            isCorruptZipEntryName: isCorruptZipEntryName,
            parentFolderFromPath: parentFolderFromPath,
            matchMediaToMeta: matchMediaToMeta,
            stem: stem,
            readJsonFile: readJsonFile,
            yearFromRecord: yearFromRecord,
            isImage: isImage,
            isVideo: isVideo,
            timestampsMatch: timestampsMatch,
            normalizeBaseName: normalizeBaseName,
            SUPPLEMENTAL: SUPPLEMENTAL,
            SUPPLEMENTAL_NO_JSON: SUPPLEMENTAL_NO_JSON
          };
          return (typeof GphotoPipelinePhases !== 'undefined' && GphotoPipelinePhases.runExtract
            ? GphotoPipelinePhases.runExtract(ctx, log)
            : Promise.reject(new Error('GphotoPipelinePhases not loaded'))
          ).then(function () {
            if (ctx.options.extractOnly === true) {
              log('Extraction only: no further processing.');
              var writeFileToDir = ctx.writeFileToDir;
              var outputHandle = ctx.outputHandle;
              if (!dryRun && writeFileToDir && outputHandle) {
                return writeFileToDir(outputHandle, 'file_map.json', new TextEncoder().encode(JSON.stringify(ctx.fileMap, null, 2)))
                  .then(function () {
                    return writeFileToDir(outputHandle, 'report.json', new TextEncoder().encode(JSON.stringify(ctx.report, null, 2)));
                  })
                  .then(function () {
                    return { report: report, summaryReport: {} };
                  });
              }
              return Promise.resolve({ report: report, summaryReport: {} });
            }
            return GphotoPipelinePhases.runIndex(ctx, log);
          })
            .then(function (result) {
              if (ctx.options.extractOnly === true) return result;
              return GphotoPipelinePhases.runMerge(ctx, log);
            })
            .then(function (result) {
              if (ctx.options.extractOnly === true) return result;
              return GphotoPipelinePhases.runReport(ctx, log);
            })
            .then(function (result) {
              if (ctx.options.extractOnly === true) return result;
              return GphotoPipelinePhases.runAlbumsAndMaps(ctx, log);
            })
            .then(function (result) { return result !== undefined ? result : { report: report, summaryReport: {} }; });
        });
      });
    });
  }

  global.GphotoPipeline = {
    scan: scan,
    apply: apply,
    listDir: listDir,
    matchMediaToMeta: matchMediaToMeta,
    readFileToBuffer: readFileToBuffer,
    runMapGeneration: typeof GphotoPipelinePhases !== 'undefined' && GphotoPipelinePhases.runMapGeneration ? GphotoPipelinePhases.runMapGeneration : function () { return Promise.reject(new Error('GphotoPipelinePhases not loaded')); }
  };
})(typeof self !== 'undefined' ? self : this);
