// 核心模块全链路测试（Node）
import fs from 'fs';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TD = path.join(__dirname, '..', 'testdata');
const parentTD = TD;
const read = f => new Uint8Array(fs.readFileSync(path.join(TD, f)));

// 动态导入被测模块
const { parseMotionPhoto, probeVideo } = await import(pathToFileURL(path.join(__dirname, 'js', 'motionphoto.js')).href);
const { extractExifFromJpeg, buildExifWithAppleMakerNote, parseTiff, readExifDate, extractExifMeta, formatAppleCreationDate } = await import(pathToFileURL(path.join(__dirname, 'js', 'exif.js')).href);
const { mp4ToLivePhotoMov, verifyLivePhotoMov, verifyVideoTrakMeta } = await import(pathToFileURL(path.join(__dirname, 'js', 'mov.js')).href);
const { patchHeicExif, loadHeicEncoder, encodeRgbaToHeic, patchHeicFtypBrand, buildAppleHeicFtypPayload } = await import(pathToFileURL(path.join(__dirname, 'js', 'heic.js')).href);

/** 解析文件开头的 ftyp 盒 */
function readFtyp(b) {
  const size = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3];
  const type = String.fromCharCode(b[4], b[5], b[6], b[7]);
  const pl = b.subarray(8, size);
  const major = String.fromCharCode(pl[0], pl[1], pl[2], pl[3]);
  const minor = (pl[4] << 24) | (pl[5] << 16) | (pl[6] << 8) | pl[7];
  const compat = [];
  for (let i = 8; i + 4 <= pl.length; i += 4) compat.push(String.fromCharCode(pl[i], pl[i + 1], pl[i + 2], pl[i + 3]));
  return { size, type, major, minor, compat };
}

let pass = 0, fail = 0;
function check(name, cond, extra = '') {
  if (cond) { pass++; console.log('  ✓', name); }
  else { fail++; console.log('  ✗', name, extra); }
}

/* ============ 1. motionphoto 拆分 ============ */
console.log('\n[1] motionphoto.js');
const live = read('hw_livephoto.jpg');
const parsed = parseMotionPhoto(live);
check('识别为动态照片', parsed.isLivePhoto, parsed.reason);
check('图片部分是 JPEG', parsed.image[0] === 0xff && parsed.image[1] === 0xd8);
check('视频部分是 MP4', String.fromCharCode(...parsed.video.slice(4, 8)) === 'ftyp');
check('视频与源 MP4 一致', Buffer.compare(Buffer.from(parsed.video), fs.readFileSync(path.join(TD, 'video_tail.mp4'))) === 0);
const jpegOnly = read('photo.jpg');
check('普通照片不误判', !parseMotionPhoto(jpegOnly).isLivePhoto);

// faststart 布局也能拆
const live2bytes = new Uint8Array([...read('photo.jpg'), ...read('video_head.mp4')]);
const parsed2 = parseMotionPhoto(live2bytes);
check('faststart 布局拆分', parsed2.isLivePhoto);

/* ============ 2. exif.js ============ */
console.log('\n[2] exif.js');
const origExif = extractExifFromJpeg(parsed.image);
check('提取到原 EXIF', origExif && origExif.length > 8, `len=${origExif && origExif.length}`);
const origParsed = parseTiff(origExif);
check('原 EXIF 含 Make=HUAWEI', (() => {
  const e = origParsed.ifd0.find(x => x.tag === 0x010f);
  return e && new TextDecoder().decode(e.value).startsWith('HUAWEI');
})());
check('原 EXIF 含 DateTimeOriginal', readExifDate(origExif)?.getFullYear() === 2026);

const uuid = 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D';
const newTiff = buildExifWithAppleMakerNote(origExif, uuid);
check('重建 TIFF 非空', newTiff.length > 100);
const reParsed = parseTiff(newTiff);
check('重建后可再解析', !!reParsed);
check('保留 Make=HUAWEI', (() => {
  const e = reParsed.ifd0.find(x => x.tag === 0x010f);
  return e && new TextDecoder().decode(e.value).startsWith('HUAWEI');
})());
check('保留 DateTimeOriginal', readExifDate(newTiff)?.getFullYear() === 2026);

/* exifMeta：提取 Make/Model/Software/GPS */
const em = extractExifMeta(origExif);
check('exifMeta 提取 Make=HUAWEI', em.make === 'HUAWEI', `make=${em.make}`);
check('exifMeta 提取 Model=P40 Pro', em.model === 'P40 Pro', `model=${em.model}`);
check('exifMeta 软件字段（华为原始可能为空）', typeof em.software === 'string');
check('exifMeta 无 GPS 时 iso6709=null', em.iso6709 === null && em.hasGps === false);

/* ISO 6709 格式：构造一个带 GPS 的 TIFF 验证 */
function buildGpsIfdTiff() {
  // 构造一个最小 TIFF：II 字节序，包含 Make/Model + GPS IFD
  // 标签：0x010F Make=Apple, 0x0110 Model=iPhone, 0x0131 Software=17.0, 0x8825 GPS 指针
  // GPS 标签：0x0001 'N', 0x0002 纬度(3 rational), 0x0003 'E', 0x0004 经度(3 rational),
  // 0x0005 altRef=0, 0x0006 海拔(1 rational)
  // 全部用 II/LE
  const IFD0_ENTRIES = 4; // Make, Model, Software, GPS ptr
  const IFD0_DATA_START = 8 + 2 + IFD0_ENTRIES * 12 + 4; // TIFF hdr + count + entries + next ptr
  let p = IFD0_DATA_START;
  const makeOff = p; p += 6; // 'Apple\0'
  const modelOff = p; p += 9; // 'iPhone 15\0'
  const softwareOff = p; p += 5; // '17.0\0'
  const gpsPtrOff = p; p += 4; // gps ifd offset
  const gpsIfdOff = p; p += 2 + 7 * 12 + 4; // GPS count + 7 entries + next

  // GPS data: lat 3 rational (37°47'20.40"), lon 3 rational (175°17'58.20"), alt 1 rational (35.332m)
  const latDataOff = p; p += 3 * 8; // 24 bytes
  const lonDataOff = p; p += 3 * 8; // 24 bytes
  const altDataOff = p; p += 8;

  const totalLen = p;
  const buf = new Uint8Array(totalLen);
  const dv = new DataView(buf.buffer);
  // Header
  buf[0] = 0x49; buf[1] = 0x49; dv.setUint16(2, 42, true); dv.setUint32(4, 8, true);

  let off = 8;
  dv.setUint16(off, IFD0_ENTRIES, true); off += 2;
  function entry(tag, type, count, valueLE) {
    dv.setUint16(off, tag, true); dv.setUint16(off + 2, type, true); dv.setUint32(off + 4, count, true);
    if (valueLE.length === 4) { dv.setUint32(off + 8, valueLE[0] | (valueLE[1] << 8) | (valueLE[2] << 16) | (valueLE[3] << 24), true); }
    else { dv.setUint32(off + 8, valueLE[0], true); }
    off += 12;
  }
  entry(0x010f, 2, 6, [makeOff, 0, 0, 0]); // Make pointer
  entry(0x0110, 2, 9, [modelOff, 0, 0, 0]); // Model pointer
  entry(0x0131, 2, 5, [softwareOff, 0, 0, 0]); // Software pointer
  entry(0x8825, 4, 1, [gpsIfdOff, 0, 0, 0]); // GPS IFD ptr
  dv.setUint32(off, 0, true); off += 4; // next IFD

  // IFD0 data
  buf.set([0x41, 0x70, 0x70, 0x6c, 0x65, 0x00], makeOff); // 'Apple\0'
  buf.set([0x69, 0x50, 0x68, 0x6f, 0x6e, 0x65, 0x20, 0x31, 0x35], modelOff); // 'iPhone 15'
  buf.set([0x31, 0x37, 0x2e, 0x30, 0x00], softwareOff); // '17.0\0'

  // GPS IFD
  dv.setUint16(gpsIfdOff, 7, true); // 7 entries
  let gp = gpsIfdOff + 2;
  // 0x0001 GPSLatitudeRef 'N' (inline)
  dv.setUint16(gp, 0x0001, true); dv.setUint16(gp + 2, 2, true); dv.setUint32(gp + 4, 2, true);
  buf[gp + 8] = 0x4e; buf[gp + 9] = 0x00; gp += 12;
  // 0x0002 GPSLatitude 3 rational (37°47'20.40")
  dv.setUint16(gp, 0x0002, true); dv.setUint16(gp + 2, 5, true); dv.setUint32(gp + 4, 3, true);
  dv.setUint32(gp + 8, latDataOff, true); gp += 12;
  // 0x0003 GPSLongitudeRef 'E'
  dv.setUint16(gp, 0x0003, true); dv.setUint16(gp + 2, 2, true); dv.setUint32(gp + 4, 2, true);
  buf[gp + 8] = 0x45; buf[gp + 9] = 0x00; gp += 12;
  // 0x0004 GPSLongitude 3 rational
  dv.setUint16(gp, 0x0004, true); dv.setUint16(gp + 2, 5, true); dv.setUint32(gp + 4, 3, true);
  dv.setUint32(gp + 8, lonDataOff, true); gp += 12;
  // 0x0005 GPSAltitudeRef=0 (above sea level)
  dv.setUint16(gp, 0x0005, true); dv.setUint16(gp + 2, 1, true); dv.setUint32(gp + 4, 1, true);
  buf[gp + 8] = 0x00; gp += 12;
  // 0x0006 GPSAltitude 1 rational
  dv.setUint16(gp, 0x0006, true); dv.setUint16(gp + 2, 5, true); dv.setUint32(gp + 4, 1, true);
  dv.setUint32(gp + 8, altDataOff, true); gp += 12;
  // (extra placeholder)
  // next IFD ptr
  dv.setUint32(gp, 0, true);

  // lat data: 37/1, 47/1, 2040/100
  function wRat(off, num, den) { dv.setUint32(off, num, true); dv.setUint32(off + 4, den, true); }
  wRat(latDataOff + 0, 37, 1); wRat(latDataOff + 8, 47, 1); wRat(latDataOff + 16, 2040, 100);
  // lon data: 175/1, 17/1, 5820/100
  wRat(lonDataOff + 0, 175, 1); wRat(lonDataOff + 8, 17, 1); wRat(lonDataOff + 16, 5820, 100);
  // alt data: 35332/1000
  wRat(altDataOff, 35332, 1000);

  return buf;
}
const gpsTiff = buildGpsIfdTiff();
const emGps = extractExifMeta(gpsTiff);
check('GPS TIFF: Make=Apple', emGps.make === 'Apple', emGps.make);
check('GPS TIFF: Model=iPhone 15', emGps.model === 'iPhone 15', emGps.model);
check('GPS TIFF: Software=17.0', emGps.software === '17.0', emGps.software);
check('GPS TIFF: hasGps=true', emGps.hasGps === true);
check('GPS TIFF: ISO 6709 格式正确', emGps.iso6709 === '+37.7890+175.2995+035.332/', `iso6709=${emGps.iso6709}`);
check('GPS TIFF: lat≈37.789', Math.abs(emGps.lat - 37.789) < 0.01, `lat=${emGps.lat}`);
check('GPS TIFF: lon≈175.299', Math.abs(emGps.lon - 175.2995) < 0.01, `lon=${emGps.lon}`);
check('GPS TIFF: alt≈35.332', Math.abs(emGps.alt - 35.332) < 0.01, `alt=${emGps.alt}`);

/* formatAppleCreationDate */
const t = new Date(2025, 9, 12, 23, 36, 14); // 2025-10-12 23:36:14 本地时区
const cdate = formatAppleCreationDate(t);
check('creationdate 格式 YYYY-MM-DDTHH:MM:SS±HHMM',
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/.test(cdate), `cdate=${cdate}`);
check('含 MakerNote (0x927C)', (() => {
  const e = reParsed.exifIfd.find(x => x.tag === 0x927c);
  return e && e.value.length === 1788; // iPhone 15 Pro Max 完整 MakerNotes 模板（29 个 Apple 私有字段）
})());
check('MakerNote 含 ContentIdentifier UUID', (() => {
  const e = reParsed.exifIfd.find(x => x.tag === 0x927c);
  const mn = e.value;
  const s = new TextDecoder().decode(mn);
  return s.startsWith('Apple iOS') && s.includes(uuid);
})());
check('MakerNote 含 PhotoIdentifier（独立 UUID）', (() => {
  const e = reParsed.exifIfd.find(x => x.tag === 0x927c);
  const mn = e.value;
  const s = new TextDecoder().decode(mn);
  // 至少能匹配到一个 UUID 格式串（不是 ContentIdentifier）
  const matches = s.match(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/g) || [];
  return matches.length >= 2 && matches[1] !== uuid.toLowerCase() && matches[1] !== uuid.toUpperCase();
})());
// 无 EXIF 时也能构建
const minimal = buildExifWithAppleMakerNote(null, uuid);
const mp2 = parseTiff(minimal);
check('无原 EXIF 时最小构建', !!mp2 && mp2.exifIfd.some(x => x.tag === 0x927c));

/* ============ 3. mov.js ============ */
console.log('\n[3] mov.js');
for (const f of ['video_tail.mp4', 'video_head.mp4']) {
  const mp4 = read(f);
  const date = readExifDate(origExif);
  const { bytes, warnings } = mp4ToLivePhotoMov(mp4, uuid, date, { photoWidth: 400, photoHeight: 300 });
  const v = verifyLivePhotoMov(bytes);
  check(`${f}: meta 结构完整`, v.ok === true, JSON.stringify(v));
  check(`${f}: hdlr=mdta`, v.handler === 'mdta');
  check(`${f}: key 正确`, v.key === 'com.apple.quicktime.content.identifier');
  check(`${f}: UUID 一致`, v.value === uuid);
  check(`${f}: ftyp 品牌=qt`, String.fromCharCode(...bytes.slice(8, 12)) === 'qt  ');
  check(`${f}: ftyp 兼容品牌全为 qt`, (() => {
    const sz = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
    for (let p = 16; p + 4 <= sz; p += 4) {
      if (String.fromCharCode(...bytes.slice(p, p + 4)) !== 'qt  ') return false;
    }
    return true;
  })());
  // stco 偏移正确性：解析 stco 并验证指向的 sample 数据完整
  const stcoOk = (() => {
    function boxes(b, s, e) {
      const out = []; let off = s;
      while (off + 8 <= e) {
        let size = ((b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3]) >>> 0;
        const t = String.fromCharCode(b[off + 4], b[off + 5], b[off + 6], b[off + 7]);
        if (size < 8) break;
        out.push({ t, off, size }); off += size;
      }
      return out;
    }
    const top = boxes(bytes, 0, bytes.length);
    const moov = top.find(c => c.t === 'moov');
    let stco = null;
    for (const c of boxes(bytes, moov.off + 8, moov.off + moov.size)) {
      if (c.t === 'trak') {
        for (const s of boxes(bytes, c.off + 8, c.off + c.size)) {
          if (s.t === 'mdia') for (const m of boxes(bytes, s.off + 8, s.off + s.size)) {
            if (m.t === 'minf') for (const n of boxes(bytes, m.off + 8, m.off + m.size)) {
              if (n.t === 'stbl') for (const q of boxes(bytes, n.off + 8, n.off + n.size)) {
                if (q.t === 'stco' && !stco) stco = q; // 取第一个（视频轨道）；最后一个可能是新插入的元数据轨道
              }
            }
          }
        }
      }
    }
    if (!stco) return false;
    const dv = new DataView(bytes.buffer);
    const cnt = dv.getUint32(stco.off + 12);
    for (let i = 0; i < cnt; i++) {
      const off = dv.getUint32(stco.off + 16 + i * 4);
      // 验证 sample 数据：合成样本第 i 个首字节为 i%251
      if (bytes[off] !== i % 251) return false;
    }
    return cnt === 10;
  })();
  check(`${f}: stco 偏移修正后数据完好`, stcoOk);
  // still-image-time 时间元数据轨道（Apple Live Photo 原生结构）
  check(`${f}: 含 still-image-time 轨道`, v.stillImageTime && v.stillImageTime.present === true, JSON.stringify(v.stillImageTime));
  check(`${f}: still-image-time 样本值=-1(0xFF)`, v.stillImageTime && v.stillImageTime.sampleValue === -1);
  check(`${f}: still-image-time 时刻=时长中点`, v.stillImageTime && v.stillImageTime.seconds > 0, `seconds=${v.stillImageTime && v.stillImageTime.seconds}`);
  check(`${f}: mebx 含 3 个键（与原生一致）`, v.stillImageTime && v.stillImageTime.keyCount === 3, `keyCount=${v.stillImageTime && v.stillImageTime.keyCount}`);
  check(`${f}: 样本 105 字节（9+80+16）`, v.stillImageTime && v.stillImageTime.sampleSize === 105, `size=${v.stillImageTime && v.stillImageTime.sampleSize}`);
  check(`${f}: 变换矩阵存在（9×float64）`, v.stillImageTime && v.stillImageTime.transformPresent === true);
  check(`${f}: reference-dimensions 存在（视频尺寸）`, v.stillImageTime && Array.isArray(v.stillImageTime.referenceDimensions) && v.stillImageTime.referenceDimensions.length === 2, JSON.stringify(v.stillImageTime && v.stillImageTime.referenceDimensions));
  check(`${f}: dtyp 16 字节（选择器+well-known 码）`, (() => {
    // 在 mebx keys 中定位 dtyp box 并验证字节数
    function boxes(b, s, e) {
      const out = []; let off = s;
      while (off + 8 <= e) {
        let size = ((b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3]) >>> 0;
        const t = String.fromCharCode(b[off + 4], b[off + 5], b[off + 6], b[off + 7]);
        if (size < 8) break;
        out.push({ t, off, size }); off += size;
      }
      return out;
    }
    const top = boxes(bytes, 0, bytes.length);
    const moov = top.find(c => c.t === 'moov');
    for (const c of boxes(bytes, moov.off + 8, moov.off + moov.size)) {
      if (c.t !== 'trak') continue;
      for (const s of boxes(bytes, c.off + 8, c.off + c.size)) if (s.t === 'mdia')
        for (const m of boxes(bytes, s.off + 8, s.off + s.size)) if (m.t === 'minf')
          for (const n of boxes(bytes, m.off + 8, m.off + m.size)) if (n.t === 'stbl')
            for (const q of boxes(bytes, n.off + 8, n.off + n.size)) if (q.t === 'stsd')
              for (const e of boxes(bytes, q.off + 16, q.off + q.size)) if (e.t === 'mebx')
                for (const k of boxes(bytes, e.off + 16, e.off + e.size)) if (k.t === 'keys')
                  for (const ka of boxes(bytes, k.off + 8, k.off + k.size)) {
                    const dtyp = boxes(bytes, ka.off + 8, ka.off + ka.size).find(x => x.t === 'dtyp');
                    if (!dtyp || dtyp.size !== 16) return false;
                    if (((bytes[dtyp.off + 8] << 24) | bytes[dtyp.off + 9] << 16 | bytes[dtyp.off + 10] << 8 | bytes[dtyp.off + 11]) !== 0) return false; // 选择器必须为 0
                  }
    }
    return true;
  })());
  fs.writeFileSync(path.join(TD, f.replace('.mp4', '_live.mov')), Buffer.from(bytes));
}

/* ============ 3b. mov.js QuickTime Keys（10 项，与 iPhone 原生同构） ============ */
console.log('\n[3b] mov.js QuickTime Keys（moov meta 完整键集）');
{
  const mp4 = read('video_tail.mp4');
  const date = new Date('2025-10-12T23:36:14+13:00');
  // 提供完整 exifMeta（含 GPS），验证 ISO 6709 和全字段写入
  const { bytes } = mp4ToLivePhotoMov(mp4, uuid, date, {
    photoWidth: 1920, photoHeight: 1440,
    exifMeta: {
      make: 'Apple',
      model: 'iPhone 15 Pro Max',
      software: '17.0',
      iso6709: '-37.7890+175.2995+035.332/',
    },
  });
  const v = verifyLivePhotoMov(bytes);
  // 10 个 keys 全部存在
  check('keys count = 10（含 GPS）', v.keyCount === 10, `keyCount=${v.keyCount} keys=${v.keys.map(k=>k.key).join('|')}`);
  function findKey(name) { return v.keys.find(k => k.key === name); }
  check('keys: location.accuracy.horizontal', findKey('com.apple.quicktime.location.accuracy.horizontal')?.value === '5.000000');
  check('keys: full-frame-rate-playback-intent=1 (type 0x15)', findKey('com.apple.quicktime.full-frame-rate-playback-intent')?.value === 1 && findKey('com.apple.quicktime.full-frame-rate-playback-intent')?.typeCode === 0x15);
  check('keys: content.identifier=UUID', findKey('com.apple.quicktime.content.identifier')?.value === uuid);
  check('keys: live-photo.vitality-score≈0.9398 (type 0x17)', Math.abs(findKey('com.apple.quicktime.live-photo.vitality-score')?.value - 0.939828753471375) < 1e-9 && findKey('com.apple.quicktime.live-photo.vitality-score')?.typeCode === 0x17);
  check('keys: live-photo.vitality-scoring-version=4', findKey('com.apple.quicktime.live-photo.vitality-scoring-version')?.value === 4);
  check('keys: location.ISO6709', findKey('com.apple.quicktime.location.ISO6709')?.value === '-37.7890+175.2995+035.332/');
  check('keys: make=Apple', findKey('com.apple.quicktime.make')?.value === 'Apple');
  check('keys: model=iPhone 15 Pro Max', findKey('com.apple.quicktime.model')?.value === 'iPhone 15 Pro Max');
  check('keys: software=17.0', findKey('com.apple.quicktime.software')?.value === '17.0');
  check('keys: creationdate 格式 ISO 8601',
    /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/.test(findKey('com.apple.quicktime.creationdate')?.value || ''),
    findKey('com.apple.quicktime.creationdate')?.value);

  // 不传 GPS 时只写 8 个 keys（省 location.accuracy.horizontal / location.ISO6709）
  const { bytes: b2 } = mp4ToLivePhotoMov(mp4, uuid, date, { photoWidth: 1920, photoHeight: 1440 });
  const v2 = verifyLivePhotoMov(b2);
  check('无 GPS 时 keys count = 8', v2.keyCount === 8, `keyCount=${v2.keyCount} keys=${v2.keys.map(k=>k.key).join('|')}`);
  check('无 GPS 时不含 ISO 6709', !v2.keys.find(k => k.key === 'com.apple.quicktime.location.ISO6709'));
}

/* ============ 3c. mov.js 视频轨内 5 个 Apple 相机 Keys（video trak meta） ============ */
console.log('\n[3c] mov.js video trak meta 5 个相机 keys（参照 iPhone 原生）');
{
  const mp4 = read('video_tail.mp4');
  const date = new Date('2025-10-12T23:36:14+13:00');
  // 提供完整 camera 信息，验证 5 项全部写入
  const { bytes } = mp4ToLivePhotoMov(mp4, uuid, date, {
    photoWidth: 1920, photoHeight: 1440,
    camera: {
      lensModel: 'iPhone 15 Pro Max back camera 6.765mm f/1.78',
      focal35mmEquivalent: '23',
      lensIrisFNumber: 'F1.78',
      makerNote74: 2,
      makerNote97: 24,
    },
  });

  // 解析 moov → trak[0] (video) → meta → keys/ilst
  const v = verifyVideoTrakMeta(bytes);
  check('video trak meta keys count = 5', v.keyCount === 5, `keyCount=${v.keyCount} keys=${v.keys.map(k=>k.key).join('|')}`);
  function findK(name) { return v.keys.find(k => k.key === name); }
  check('video trak meta: lens_model=iPhone 15 Pro Max...', findK('com.apple.quicktime.camera.lens_model')?.value === 'iPhone 15 Pro Max back camera 6.765mm f/1.78');
  check('video trak meta: focal_length.35mm_equivalent=23', findK('com.apple.quicktime.camera.focal_length.35mm_equivalent')?.value === '23');
  check('video trak meta: lens_irisfnumber=F1.78', findK('com.apple.quicktime.camera.lens_irisfnumber')?.value === 'F1.78');
  check('video trak meta: apple-maker-note.74=2 (int32 BE)', findK('com.apple.quicktime.apple-maker-note.74')?.value === 2 && findK('com.apple.quicktime.apple-maker-note.74')?.typeCode === 0x15);
  check('video trak meta: apple-maker-note.97=24 (int32 BE)', findK('com.apple.quicktime.apple-maker-note.97')?.value === 24 && findK('com.apple.quicktime.apple-maker-note.97')?.typeCode === 0x15);
  check('video trak meta: hdlr=mhlr', v.handler === 'mhlr', `handler=${v.handler}`);

  // 默认占位值（不传 camera）
  const { bytes: b3 } = mp4ToLivePhotoMov(mp4, uuid, date, { photoWidth: 1920, photoHeight: 1440 });
  const v3 = verifyVideoTrakMeta(b3);
  check('无 camera 时使用默认 iPhone 占位 lens', v3.keys.find(k => k.key === 'com.apple.quicktime.camera.lens_model')?.value === 'iPhone back camera 6.86mm f/1.78');
  check('无 camera 时默认 focal=26', v3.keys.find(k => k.key === 'com.apple.quicktime.camera.focal_length.35mm_equivalent')?.value === '26');
  check('无 camera 时默认 iris=F1.78', v3.keys.find(k => k.key === 'com.apple.quicktime.camera.lens_irisfnumber')?.value === 'F1.78');

  fs.writeFileSync(path.join(TD, 'video_trak_meta.mov'), Buffer.from(bytes));
}

/* ============ 4. heic.js ============ */
console.log('\n[4] heic.js（wasm 编码）');
// 在 Node 中直接给 wasm 提供二进制（浏览器走 fetch）
const encoderUrl = new URL('../vendor/apple-hdr-heic.wasm', import.meta.url).href;
globalThis.__testWasmPath = path.join(__dirname, 'vendor', 'apple-hdr-heic.wasm');
// heic.js 的 loadHeicEncoder 用 fetch —— 在 Node 模拟
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
  if (String(url).includes('apple-hdr-heic.wasm')) {
    const data = fs.readFileSync(globalThis.__testWasmPath);
    return { ok: true, arrayBuffer: async () => data };
  }
  return realFetch(url);
};
// 也拦截动态 import 的 vendor 模块 —— heic.js import('../vendor/apple-hdr-heic.js')
// Node 下该文件存在（稍后拷贝），直接可用。

// RGBA 测试图（与 photo.jpg 同尺寸 400x300）
const W = 400, H = 300;
const rgba = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
  const i = (y * W + x) * 4;
  rgba[i] = (x * 255 / W) | 0; rgba[i + 1] = (y * 255 / H) | 0; rgba[i + 2] = 90; rgba[i + 3] = 255;
}
const heic1 = await encodeRgbaToHeic(rgba, W, H, 90);
check('HEIC 编码成功', heic1.length > 500 && String.fromCharCode(...heic1.slice(8, 12)) === 'heic', `len=${heic1.length}`);

/* ---------- [4b] ftyp 品牌改写为 Apple 兼容列表 ---------- */
console.log('\n[4b] HEIC ftyp 品牌（Apple 兼容）');
const APPLE_COMPAT = 'mif1,MiHB,MiHE,MiPr,miaf,heic,tmap';
check('Apple ftyp 载荷为 36 字节', buildAppleHeicFtypPayload().length === 36);

const f1 = readFtyp(heic1);
check('改写前：libheif 只写 3 个兼容品牌', f1.compat.join(',') === 'mif1,heic,miaf', f1.compat.join(','));

const heic1b = patchHeicFtypBrand(heic1);
const f2 = readFtyp(heic1b);
check('ftyp 盒尺寸 28 → 44', f1.size === 28 && f2.size === 44, `${f1.size} → ${f2.size}`);
check('文件整体增长 16 字节', heic1b.length === heic1.length + 16);
check('ftyp 类型仍为 ftyp', f2.type === 'ftyp');
check('major brand = heic', f2.major === 'heic', f2.major);
check('minor version = 0', f2.minor === 0, String(f2.minor));
check('兼容品牌与 iPhone 原生一致', f2.compat.join(',') === APPLE_COMPAT, f2.compat.join(','));
check('含 Apple 私有品牌 MiHB/MiHE/MiPr/tmap',
  ['MiHB', 'MiHE', 'MiPr', 'tmap'].every(x => f2.compat.includes(x)));
check('保留 mif1 保证通用解码器可用', f2.compat.includes('mif1'));

// 关键：ftyp 变长后 iloc 偏移必须同步修正，否则元数据/图像数据读不出来
/** 解析 meta/iloc，返回每个 item 的绝对偏移与长度 */
function parseIloc(b) {
  const top = topBoxes(b);
  const meta = top.find(x => x.type === 'meta');
  const subs = [];
  let off = meta.off + 12; // meta 是 full box
  while (off + 8 <= meta.off + meta.size) {
    const size = (b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3];
    const type = String.fromCharCode(b[off + 4], b[off + 5], b[off + 6], b[off + 7]);
    if (size < 8) break;
    subs.push({ type, off, size });
    off += size;
  }
  const iloc = subs.find(x => x.type === 'iloc');
  const version = b[iloc.off + 8];
  const offsetSize = b[iloc.off + 12] >> 4, lengthSize = b[iloc.off + 12] & 15;
  const baseOffsetSize = b[iloc.off + 13] >> 4;
  const indexSize = (version === 1 || version === 2) ? (b[iloc.off + 13] & 15) : 0;
  const readN = (pos, n) => { let v = 0; for (let i = 0; i < n; i++) v = v * 256 + b[pos + i]; return v; };
  let q = iloc.off + 14;
  let count;
  if (version < 2) { count = (b[q] << 8) | b[q + 1]; q += 2; }
  else { count = (b[q] << 24) | (b[q + 1] << 16) | (b[q + 2] << 8) | b[q + 3]; q += 4; }
  const items = [];
  for (let i = 0; i < count; i++) {
    const id = version < 2 ? readN(q, 2) : readN(q, 4);
    q += version < 2 ? 2 : 4;
    q += 2; // data_reference_index
    if (version >= 1) q += 2; // construction_method
    const base = readN(q, baseOffsetSize); q += baseOffsetSize;
    const ec = readN(q, 2); q += 2;
    const ext = [];
    for (let e = 0; e < ec; e++) {
      let ep = q;
      if (indexSize) ep += indexSize;
      ext.push({ off: readN(ep, offsetSize), len: readN(ep + offsetSize, lengthSize) });
      q = ep + offsetSize + lengthSize;
    }
    items.push({ id, base, ext, abs: base + ext.reduce((s, x) => s + x.off, 0) });
  }
  return items;
}

function topBoxes(b) {
  const out = [];
  let off = 0;
  while (off + 8 <= b.length) {
    const size = (b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3];
    const type = String.fromCharCode(b[off + 4], b[off + 5], b[off + 6], b[off + 7]);
    if (size < 8) break;
    out.push({ type, off, size });
    off += size;
  }
  return out;
}
{
  const a = topBoxes(heic1), c = topBoxes(heic1b);
  check('顶层盒顺序不变（ftyp/meta/mdat）',
    a.map(x => x.type).join(',') === c.map(x => x.type).join(','), c.map(x => x.type).join(','));
  check('meta 尺寸不变', a[1].size === c[1].size, `${a[1].size} vs ${c[1].size}`);
  check('mdat 起点整体后移 16 字节', c[2].off === a[2].off + 16, `${a[2].off} → ${c[2].off}`);
  check('mdat 载荷逐字节一致（仅位移未损坏）', Buffer.compare(
    Buffer.from(heic1.subarray(a[2].off + 8, a[2].off + a[2].size)),
    Buffer.from(heic1b.subarray(c[2].off + 8, c[2].off + c[2].size))) === 0);

  // iloc 偏移必须同步 +16，否则解析器读不到图像/元数据
  const ia = parseIloc(heic1), ic = parseIloc(heic1b);
  check('iloc 条目数不变', ia.length === ic.length, `${ia.length} vs ${ic.length}`);
  check('iloc 所有偏移整体 +16', ia.length === ic.length && ia.every((x, i) => ic[i].abs === x.abs + 16),
    ia.map((x, i) => `${x.id}:${x.abs}→${ic[i] && ic[i].abs}`).join(' '));
  check('iloc 所有长度不变', ia.length === ic.length && ia.every((x, i) =>
    x.ext.length === ic[i].ext.length && x.ext.every((e, k) => e.len === ic[i].ext[k].len)));
}

const heic2 = patchHeicExif(heic1b, newTiff);
check('Exif 注入成功（文件变长）', heic2.length === heic1b.length + 4 + newTiff.length);
const f3 = readFtyp(heic2);
check('Exif 注入后 ftyp 品牌未被破坏', f3.compat.join(',') === APPLE_COMPAT, f3.compat.join(','));
fs.writeFileSync(path.join(TD, 'out_livephoto.heic'), Buffer.from(heic2));

/* ---------- [5] JPEG 路径：原地注入 MakerNote（不重新编码） ---------- */
console.log('\n[5] JPEG 配对标识注入（无损路径）');
const { patchJpegWithAppleMakerNote } = await import(pathToFileURL(path.join(__dirname, 'js', 'exif.js')).href);

const jpegUuid = 'AAAABBBB-CCCC-DDDD-EEEE-FFFF00001111';
const jpegOut = patchJpegWithAppleMakerNote(parsed.image, jpegUuid);
check('JPEG 注入返回结果', !!jpegOut);
if (jpegOut) {
  check('输出仍是 JPEG（SOI/EOI 完好）',
    jpegOut[0] === 0xff && jpegOut[1] === 0xd8 &&
    jpegOut[jpegOut.length - 2] === 0xff && jpegOut[jpegOut.length - 1] === 0xd9);
  const s = Buffer.from(jpegOut).toString('latin1');
  check('JPEG 内含注入的 UUID', s.includes(jpegUuid));
  // 图像数据段必须与原始完全一致（零重编码 = 无损）
  const origSos = Buffer.from(parsed.image).indexOf(Buffer.from([0xff, 0xda]));
  const outSos = Buffer.from(jpegOut).indexOf(Buffer.from([0xff, 0xda]));
  check('SOS 之后图像数据未改动（无损）',
    origSos > 0 && outSos > 0 &&
    Buffer.compare(
      Buffer.from(parsed.image.subarray(origSos)),
      Buffer.from(jpegOut.subarray(outSos))
    ) === 0);
  // 重新提取 EXIF，确认 UUID 可被解析出来
  const { extractExifFromJpeg: ex2 } = await import(pathToFileURL(path.join(__dirname, 'js', 'exif.js')).href);
  const tiff2 = ex2(jpegOut);
  check('注入后仍可提取 EXIF', !!tiff2);
  check('注入后仍保留 Make=HUAWEI', (() => {
    const t = Buffer.from(tiff2).toString('latin1');
    return t.includes('HUAWEI');
  })());
  fs.writeFileSync(path.join(TD, 'out_livephoto.jpg'), Buffer.from(jpegOut));
}

console.log(`\n结果: ${pass} 通过, ${fail} 失败`);

/* ---------- [6] 极简配对模式（makelive 兼容 / Synology） ---------- */
console.log('\n[6] 极简配对模式 MOV（makelive 兼容）');
{
  const { mp4ToLivePhotoMovMinimal, verifyLivePhotoMov } = await import(pathToFileURL(path.join(__dirname, 'js', 'mov.js')).href);
  const mp4 = fs.readFileSync(path.join(parentTD, 'real.mp4'));
  const mUuid = 'DDDDDDDD-3333-4CCC-9DDD-EEEEFFFF0000';
  const { bytes: minBytes, warnings } = mp4ToLivePhotoMovMinimal(new Uint8Array(mp4), mUuid, null, {});
  check('极简模式返回字节', minBytes && minBytes.length > 0, (minBytes || []).length + '');
  const mv = verifyLivePhotoMov(minBytes);
  check('极简 MOV 结构可验证', mv.ok === true);
  check('极简 MOV 含 ContentIdentifier', mv.value === mUuid, String(mv.value));
  check('极简 MOV 仅 1 个 QuickTime Key', mv.keyCount === 1, 'keyCount=' + mv.keyCount);
  check('极简 MOV 无 still-image-time 轨道', mv.stillImageTime.present === false);
  // ftyp 应规范化为 mp42（与 makelivephoto.com 参照一致；不能用 qt 品牌）
  // ftyp: size(4)@0 + 'ftyp'(4)@4 + major(4)@8
  const ft = Buffer.from(minBytes).toString('latin1', 8, 12);
  check('极简 MOV ftyp major=mp42', ft === 'mp42', ft);
  // 视频流仍合法（用 ffprobe 之外，检查 moov 后仍有 mdat 且尺寸合理）
  const hasMdat = Buffer.from(minBytes).includes(Buffer.from([...Buffer.from('mdat')]));
  check('极简 MOV 保留 mdat（视频流未丢）', hasMdat);
  fs.writeFileSync(path.join(TD, 'out_minimal.mov'), Buffer.from(minBytes));
}

/* ---------- [7] 极简模式保留拍摄时间 + GPS 地点 ---------- */
console.log('\n[7] 极简模式保留拍摄时间 + GPS 地点');
{
  const { buildExifMinimalAppleMakerNote, parseTiff, extractExifMeta } = await import(pathToFileURL(path.join(__dirname, 'js', 'exif.js')).href);
  const { mp4ToLivePhotoMovMinimal, verifyLivePhotoMov } = await import(pathToFileURL(path.join(__dirname, 'js', 'mov.js')).href);
  const uuid7 = 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D';

  // 7.1 拍摄时间：hw_livephoto.jpg 原 EXIF 的 DateTimeOriginal 应保留到 IFD0 ModifyDate(0x0132)
  {
    const live = read('hw_livephoto.jpg');
    const parsed7 = parseMotionPhoto(live);
    const tiff7 = extractExifFromJpeg(parsed7.image);
    const newTiff = buildExifMinimalAppleMakerNote(tiff7, uuid7);
    const np = parseTiff(newTiff);
    check('极简 JPG 保留 ModifyDate(0x0132)', np.ifd0.some(e => e.tag === 0x0132));
    const md = np.ifd0.find(e => e.tag === 0x0132);
    const mdStr = md ? new TextDecoder().decode(md.value).replace(/\0+$/, '') : '';
    check('ModifyDate 值 = 拍摄时间(2026)', mdStr.startsWith('2026'), mdStr);
    // 不写 Make 字段（既不伪装 Apple，也不保留 HUAWEI）；保留设备型号 Model 原值
    const make7 = np.ifd0.find(e => e.tag === 0x010f);
    check('极简 JPG 不含 Make(0x010F)', !make7, make7 ? new TextDecoder().decode(make7.value) : '无 Make');
    const model7 = np.ifd0.find(e => e.tag === 0x0110);
    check('极简 JPG 保留设备型号 Model(0x0110)', !!model7 && model7.value && model7.value.length > 0);
    if (model7) {
      const mStr = new TextDecoder().decode(model7.value).replace(/\0+$/, '');
      check('Model 值 = P40 Pro', mStr === 'P40 Pro', mStr);
    }
    // 拍摄参数应被保留（ExifIFD 含 ExposureTime/ISO 等）。
    // 注：hw_livephoto.jpg 的原始 EXIF 只有 ExposureTime(0x829a, RATIONAL) 和 ISO(0x8827, SHORT)，
    //     没有 FNumber/FocalLength；ExposureTime 为 RATIONAL 类型，其字节序转换正确即代表
    //     所有 RATIONAL 参数（FNumber/FocalLength）的转换逻辑同样正确。
    const exifTags7 = (np.exifIfd || []).map(e => e.tag);
    check('保留曝光时间 ExposureTime(0x829A)', exifTags7.includes(0x829a));
    check('保留 ISO(0x8827)', exifTags7.includes(0x8827));
  }

  // 7.2 GPS 地点：构造带 GPS 的 TIFF，验证 GPS IFD 全量保留
  {
    const gpsTiff = buildGpsIfdTiff();
    const newTiff = buildExifMinimalAppleMakerNote(gpsTiff, uuid7);
    const np = parseTiff(newTiff);
    check('带 GPS 时输出含 GPS IFD 指针(0x8825)', np.ifd0.some(e => e.tag === 0x8825));
    check('GPS IFD 保留 6 个标准字段', np.gpsIfd && np.gpsIfd.filter(e => e.tag >= 0x0001 && e.tag <= 0x0006).length === 6,
      np.gpsIfd ? np.gpsIfd.map(e => '0x' + e.tag.toString(16)).join(',') : '无');
    // 字节序正确：GPSLatitude 3 个 rational 应能被 extractExifMeta 正确读回
    const meta7 = extractExifMeta(newTiff);
    check('重建后 GPS lat≈37.789', Math.abs(meta7.lat - 37.789) < 0.01, `lat=${meta7.lat}`);
    check('重建后 GPS lon≈175.299', Math.abs(meta7.lon - 175.2995) < 0.01, `lon=${meta7.lon}`);
    check('重建后 ISO6709 正确', meta7.iso6709 === '+37.7890+175.2995+035.332/', meta7.iso6709);
  }

  // 7.3 MP4：拍摄时间写入 mvhd + GPS 写入 location key
  {
    const mp4 = fs.readFileSync(path.join(parentTD, 'real.mp4'));
    const live = read('hw_livephoto.jpg');
    const parsed7 = parseMotionPhoto(live);
    const tiff7 = extractExifFromJpeg(parsed7.image);
    const photoDate7 = readExifDate(tiff7);
    // 无 GPS：纯极简（仅 1 key），但 mvhd 时间 = 拍摄时间
    const r1 = mp4ToLivePhotoMovMinimal(new Uint8Array(mp4), uuid7, photoDate7, {});
    const v1 = verifyLivePhotoMov(r1.bytes);
    check('MP4 无 GPS 时仍仅 1 key', v1.keyCount === 1, 'keyCount=' + v1.keyCount);

    // 带 GPS：2 keys（content.identifier + location.ISO6709）
    const gpsMeta = extractExifMeta(buildGpsIfdTiff());
    const r2 = mp4ToLivePhotoMovMinimal(new Uint8Array(mp4), uuid7, null, { exifMeta: gpsMeta });
    const v2 = verifyLivePhotoMov(r2.bytes);
    check('MP4 带 GPS 时 2 keys', v2.keyCount === 2, 'keyCount=' + v2.keyCount);
    const loc = v2.keys.find(k => k.key === 'com.apple.quicktime.location.ISO6709');
    check('MP4 location.ISO6709 值正确', loc && loc.value === '+37.7890+175.2995+035.332/', loc && String(loc.value));
    check('MP4 ContentIdentifier 仍正确', v2.value === uuid7, String(v2.value));
  }
}
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
process.exit(fail ? 1 : 0);
