'use strict';

const { open } = require('node:fs/promises');

const MAX_MOOV_BYTES = 32 * 1024 * 1024;
const CONTAINER_TYPES = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl']);

function typeAt(buffer, offset) {
  return buffer.toString('latin1', offset, offset + 4);
}

function uint64At(buffer, offset) {
  const value = buffer.readBigUInt64BE(offset);
  if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('MP4 box exceeds the supported file size.');
  return Number(value);
}

function boxAt(buffer, offset, limit) {
  if (offset + 8 > limit) throw new Error('Truncated MP4 box header.');
  let size = buffer.readUInt32BE(offset);
  const type = typeAt(buffer, offset + 4);
  let headerSize = 8;
  if (size === 1) {
    if (offset + 16 > limit) throw new Error(`Truncated extended ${type} box header.`);
    size = uint64At(buffer, offset + 8);
    headerSize = 16;
  } else if (size === 0) {
    size = limit - offset;
  }
  if (size < headerSize || offset + size > limit) throw new Error(`Invalid ${type} box size.`);
  return { type, start: offset, payload: offset + headerSize, end: offset + size, size };
}

function directChildren(buffer, start, end) {
  const boxes = [];
  for (let offset = start; offset < end;) {
    const box = boxAt(buffer, offset, end);
    boxes.push(box);
    if (!box.size) throw new Error('Zero-length MP4 box.');
    offset = box.end;
  }
  return boxes;
}

function child(buffer, parent, type) {
  return directChildren(buffer, parent.payload, parent.end).find((candidate) => candidate.type === type);
}

function handlerType(buffer, mdia) {
  const hdlr = child(buffer, mdia, 'hdlr');
  if (!hdlr || hdlr.payload + 12 > hdlr.end) return '';
  return typeAt(buffer, hdlr.payload + 8);
}

function sampleEntries(buffer, mdia) {
  const minf = child(buffer, mdia, 'minf');
  const stbl = minf && child(buffer, minf, 'stbl');
  const stsd = stbl && child(buffer, stbl, 'stsd');
  if (!stsd || stsd.payload + 8 > stsd.end) return [];
  const count = buffer.readUInt32BE(stsd.payload + 4);
  const entries = [];
  let offset = stsd.payload + 8;
  for (let index = 0; index < count; index += 1) {
    if (offset + 8 > stsd.end) throw new Error('Truncated MP4 sample description.');
    const entry = boxAt(buffer, offset, stsd.end);
    entries.push(entry.type);
    offset = entry.end;
  }
  return entries;
}

function inspectMoov(buffer) {
  const root = boxAt(buffer, 0, buffer.length);
  if (root.type !== 'moov' || root.end !== buffer.length) throw new Error('Invalid MP4 movie box.');
  const tracks = [];
  for (const trak of directChildren(buffer, root.payload, root.end).filter((box) => box.type === 'trak')) {
    const mdia = child(buffer, trak, 'mdia');
    if (!mdia) continue;
    tracks.push({ handler: handlerType(buffer, mdia), codecs: sampleEntries(buffer, mdia) });
  }
  return tracks;
}

async function readAt(handle, length, position) {
  const buffer = Buffer.alloc(length);
  const { bytesRead } = await handle.read(buffer, 0, length, position);
  if (bytesRead !== length) throw new Error('Truncated MP4 file.');
  return buffer;
}

async function inspectMp4(filePath, { requireAudio = false } = {}) {
  const handle = await open(filePath, 'r');
  try {
    const { size: fileSize } = await handle.stat();
    if (fileSize < 24) throw new Error('MP4 output is empty or truncated.');
    const topLevel = [];
    let tracks = [];
    for (let position = 0; position < fileSize;) {
      const prefix = await readAt(handle, Math.min(16, fileSize - position), position);
      if (prefix.length < 8) throw new Error('Truncated MP4 top-level box.');
      let size = prefix.readUInt32BE(0);
      const type = typeAt(prefix, 4);
      let headerSize = 8;
      if (size === 1) {
        if (prefix.length < 16) throw new Error(`Truncated extended ${type} box header.`);
        size = uint64At(prefix, 8);
        headerSize = 16;
      } else if (size === 0) {
        size = fileSize - position;
      }
      if (size < headerSize || position + size > fileSize) throw new Error(`Invalid top-level ${type} box size.`);
      topLevel.push(type);
      if (type === 'moov') {
        if (size > MAX_MOOV_BYTES) throw new Error('MP4 movie metadata is unexpectedly large.');
        tracks = inspectMoov(await readAt(handle, size, position));
      }
      position += size;
    }

    if (!topLevel.includes('ftyp')) throw new Error('MP4 output is missing its file-type box.');
    if (!topLevel.includes('moov')) throw new Error('MP4 output is missing its movie metadata.');
    if (!topLevel.includes('mdat')) throw new Error('MP4 output contains no media payload.');
    const video = tracks.find((track) => track.handler === 'vide');
    if (!video || !video.codecs.some((codec) => codec === 'avc1' || codec === 'avc3')) {
      throw new Error('MP4 output does not contain H.264 video.');
    }
    const audio = tracks.find((track) => track.handler === 'soun');
    if (requireAudio && (!audio || !audio.codecs.includes('mp4a'))) {
      throw new Error('MP4 output does not contain AAC audio.');
    }
    return {
      bytes: fileSize,
      fragmented: topLevel.includes('moof'),
      topLevel,
      tracks,
    };
  } finally {
    await handle.close();
  }
}

module.exports = { inspectMp4, inspectMoov };
