# Copyright 2021 Joe Drago. All rights reserved.
# SPDX-License-Identifier: BSD-2-Clause

# READ THIS WHOLE COMMENT FIRST, BEFORE RUNNING THIS SCRIPT:

# The goal of this script is to detect AVIFs containing multiple adjacent iref boxes and merge them,
# filling leftover space with a free box (to avoid ruining file offsets elsewhere in the file). The
# syntax is simple:

#     coffee irefmerge.coffee filename.avif

# This will look over the file's contents and if it detects multiple irefs, it will fix it in
# memory, make a adjacent backup of the file (filename.avif.irefmergeBackup), and then overwrite the
# original file with the fixed contents. Using -v on the commandline will enable Verbose mode, and
# using -n will disable the creation of backups (.irefmergeBackup files).

# This should be well-behaved on files created by old versions of avifenc, but **PLEASE** make
# backups of your images before running this script on them, **especially** if you plan to run with
# "-n". I do not advise running this script on AVIFs generated by anything other than avifenc.

# Possible responses for a file:
# * [NotAvif] This file isn't an AVIF.
# * [BadAvif] This file thinks it is an AVIF, but is missing important things.
# * [Skipped] This file is an AVIF, but didn't need any fixes.
# * [Success] This file is an AVIF, had to be fixed, and was fixed.
# * (the script crashes) I probably have a bug; let me know.

# Note on CoffeeScript:
# If you don't want to invoke coffeescript every time, you can compile it once with:
#     coffee -c -b irefmerge.coffee
# ... and run the adjacent irefmerge.js with node instead. "It's just JavaScript."

# -------------------------------------------------------------------------------------------------
# Syntax

syntax = ->
  console.log "Syntax: irefmerge [-v] [-n] file1 [file2 ...]"
  console.log "        -v : Verbose mode"
  console.log "        -n : No Backups (Don't generate adjacent .irefmergeBackup files when overwriting in-place)"

# -------------------------------------------------------------------------------------------------
# Constants and helpers

fs = require 'fs'

INDENT = "         "
VERBOSE = false

verboseLog = ->
  if VERBOSE
    console.log.apply(null, arguments)

fatalError = (reason) ->
  console.error "ERROR: #{reason}"
  process.exit(1)

# -------------------------------------------------------------------------------------------------
# Box

class Box
  constructor: (@filename, @type, @buffer, @start, @size) ->
    @offset = @start
    @bytesLeft = @size
    @version = 0
    @flags = 0
    @boxes = {} # child boxes

  nextBox: ->
    if @bytesLeft < 8
      return null
    boxSize = @buffer.readUInt32BE(@offset)
    boxType = @buffer.toString('utf8', @offset + 4, @offset + 8)
    if boxSize > @bytesLeft
      verboseLog("#{INDENT} * Truncated box of type #{boxType} (#{boxSize} bytes with only #{@bytesLeft} bytes left)")
      return null
    if boxSize < 8
      verboseLog("#{INDENT} * Bad box size of type #{boxType} (#{boxSize} bytes")
      return null
    newBox = new Box(@filename, boxType, @buffer, @offset + 8, boxSize - 8)
    @offset += boxSize
    @bytesLeft -= boxSize
    verboseLog "#{INDENT} * Discovered box type: #{newBox.type} offset: #{newBox.offset - 8} size: #{newBox.size + 8}"
    return newBox

  walkBoxes: ->
    while box = @nextBox()
      @boxes[box.type] = box
    return

  readFullBoxHeader: ->
    if @bytesLeft < 4
      fatalError("#{INDENT} * Truncated FullBox of type #{boxType} (only #{@bytesLeft} bytes left)")
    versionAndFlags = @buffer.readUInt32BE(@offset)
    @version = (versionAndFlags >> 24) & 0xFF
    @flags = versionAndFlags & 0xFFFFFF
    @offset += 4
    @bytesLeft -= 4
    return

  ftypHasBrand: (brand) ->
    if @type != 'ftyp'
      fatalError("#{INDENT} * Calling Box.ftypHasBrand() on a non-ftyp box")
    majorBrand = @buffer.toString('utf8', @offset, @offset + 4)
    compatibleBrands = []
    compatibleBrandCount = Math.floor((@size - 8) / 4)
    for i in [0...compatibleBrandCount]
      o = @offset + 8 + (i * 4)
      compatibleBrand = @buffer.toString('utf8', o, o + 4)
      compatibleBrands.push compatibleBrand

    verboseLog "#{INDENT}   * ftyp majorBrand: #{majorBrand} compatibleBrands: [#{compatibleBrands.join(', ')}]"

    if majorBrand == brand
      return true
    for compatibleBrand in compatibleBrands
      if compatibleBrand == brand
        return true
    return false

# -------------------------------------------------------------------------------------------------
# Main

irefMerge = (filename, makeBackups) ->
  if not fs.existsSync(filename)
    fatalError("File doesn't exist: #{filename}")
  try
    fileBuffer = fs.readFileSync(filename)
  catch e
    fatalError "Failed to read \"#{filename}\": #{e}"

  fileBox = new Box(filename, "<file>", fileBuffer, 0, fileBuffer.length)
  fileBox.walkBoxes()

  ftypBox = fileBox.boxes.ftyp
  if not ftypBox?
    return "NotAvif"
  if ftypBox.type != 'ftyp'
    return "NotAvif"
  if !ftypBox.ftypHasBrand('avif')
    return "NotAvif"

  metaBox = fileBox.boxes.meta
  if not metaBox?
    return "BadAvif"
  metaBox.readFullBoxHeader()

  merged = false
  irefs = []
  while box = metaBox.nextBox()
    if box.type == 'iref'
      irefs.push box

  # console.log irefs
  if irefs.length > 1
    verboseLog "#{INDENT} * Discovered multiple (#{irefs.length}) iref boxes, merging..."
    # merge irefs, and leave a free block in the dead space
    newTotalSize = 8 + 4 # the new single iref header's size + fullbox
    for iref in irefs
      newTotalSize += iref.size - 4
    fileBuffer.writeUInt32BE(newTotalSize, irefs[0].start - 8)

    writeOffset = irefs[0].start + 4 # skip past the fullbox's version[1]+flags[3]
    for iref in irefs
      fileBuffer.copy(fileBuffer, writeOffset, iref.start + 4, iref.start + iref.size)
      writeOffset += iref.size - 4
    freeBoxSize = (irefs.length - 1) * 12
    freeBox = Buffer.alloc(freeBoxSize)
    freeBox.fill(0)
    freeBox.writeUInt32BE(freeBoxSize)
    freeBox.write("free", 4)
    freeBox.copy(fileBuffer, writeOffset, 0, freeBoxSize)
    verboseLog "#{INDENT}   * Wrote a free chunk of size #{freeBoxSize} at offset #{writeOffset}"
    merged = true

  if merged
    if makeBackups
      backupFilename = filename + ".irefmergeBackup"
      fs.writeFileSync(backupFilename, fs.readFileSync(filename))
    fs.writeFileSync(filename, fileBuffer)
    return "Success"
  return "Skipped"

main = ->
  showSyntax = false
  makeBackups = true
  files = []

  for arg in process.argv.slice(2)
    switch arg
      when '-h', '--help'
        showSyntax = true
        break
      when '-n', '--no-backups'
        makeBackups = false
        break
      when '-v', '--verbose'
        VERBOSE = true
        break
      else
        files.push arg

  if showSyntax or files.length == 0
    return syntax()

  for filename in files
    verboseLog("[Reading] #{filename}")
    result = irefMerge(filename, makeBackups)
    console.log("[#{result}] #{filename}") # Always print this

  return 0

main()
