/**************************************************************************
    copyright            : (C) 2026 by Ryan Francesconi
 **************************************************************************/

/***************************************************************************
 *   This library is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU Lesser General Public License version   *
 *   2.1 as published by the Free Software Foundation.                     *
 *                                                                         *
 *   This library is distributed in the hope that it will be useful, but   *
 *   WITHOUT ANY WARRANTY; without even the implied warranty of            *
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU     *
 *   Lesser General Public License for more details.                       *
 *                                                                         *
 *   You should have received a copy of the GNU Lesser General Public      *
 *   License along with this library; if not, write to the Free Software   *
 *   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA         *
 *   02110-1301  USA                                                       *
 *                                                                         *
 *   Alternatively, this file is available under the Mozilla Public        *
 *   License Version 1.1.  You may obtain a copy of the License at         *
 *   http://www.mozilla.org/MPL/                                           *
 ***************************************************************************/

#include "mp4qtchapterlist.h"

#include <cmath>
#include <vector>

#include "tdebug.h"
#include "mp4file.h"
#include "mp4atom.h"

using namespace TagLib;

namespace
{

  // -- Atom building helpers ------------------------------------------------

  ByteVector renderAtom(const ByteVector &name, const ByteVector &data)
  {
    return ByteVector::fromUInt(data.size() + 8) + name + data;
  }

  //! Build a full-box (version + flags) atom.
  ByteVector renderFullBox(const ByteVector &name, unsigned char version,
                           unsigned int flags, const ByteVector &data)
  {
    ByteVector vf;
    vf.append(static_cast<char>(version));
    vf.append(ByteVector::fromUInt(flags).mid(1, 3));  // 3 bytes of flags
    vf.append(data);
    return renderAtom(name, vf);
  }

  // -- Parent / offset fixup (mirrors mp4chapterlist.cpp) -------------------

  void updateParentSizes(TagLib::File *file, const MP4::AtomList &path,
                         offset_t delta, int ignore = 0)
  {
    if(static_cast<int>(path.size()) <= ignore)
      return;

    auto itEnd = path.end();
    std::advance(itEnd, 0 - ignore);

    for(auto it = path.begin(); it != itEnd; ++it) {
      file->seek((*it)->offset());
      if(const long size = file->readBlock(4).toUInt(); size == 1) {
        file->seek(4, TagLib::File::Current);
        const long long longSize = file->readBlock(8).toLongLong();
        file->seek((*it)->offset() + 8);
        file->writeBlock(ByteVector::fromLongLong(longSize + delta));
      }
      else {
        file->seek((*it)->offset());
        file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(size + delta)));
      }
    }
  }

  void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms,
                          offset_t delta, offset_t offset)
  {
    if(const MP4::Atom *moov = atoms->find("moov")) {
      const MP4::AtomList stco = moov->findall("stco", true);
      for(const auto &atom : stco) {
        if(atom->offset() > offset)
          atom->addToOffset(delta);
        if(atom->length() < 16)
          continue;
        file->seek(atom->offset() + 12);
        ByteVector data = file->readBlock(atom->length() - 12);
        unsigned int count = data.toUInt();
        file->seek(atom->offset() + 16);
        unsigned int pos = 4;
        const unsigned int maxPos = data.size() - 4;
        while(count-- && pos <= maxPos) {
          auto o = static_cast<offset_t>(data.toUInt(pos));
          if(o > offset)
            o += delta;
          file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(o)));
          pos += 4;
        }
      }

      const MP4::AtomList co64 = moov->findall("co64", true);
      for(const auto &atom : co64) {
        if(atom->offset() > offset)
          atom->addToOffset(delta);
        if(atom->length() < 20)
          continue;
        file->seek(atom->offset() + 12);
        ByteVector data = file->readBlock(atom->length() - 12);
        unsigned int count = data.toUInt();
        file->seek(atom->offset() + 16);
        unsigned int pos = 4;
        const unsigned int maxPos = data.size() - 8;
        while(count-- && pos <= maxPos) {
          long long o = data.toLongLong(pos);
          if(o > offset)
            o += delta;
          file->writeBlock(ByteVector::fromLongLong(o));
          pos += 8;
        }
      }
    }

    if(const MP4::Atom *moof = atoms->find("moof")) {
      const MP4::AtomList tfhd = moof->findall("tfhd", true);
      for(const auto &atom : tfhd) {
        if(atom->offset() > offset)
          atom->addToOffset(delta);
        if(atom->length() < 24)
          continue;
        file->seek(atom->offset() + 9);
        ByteVector data = file->readBlock(atom->length() - 9);
        if(const unsigned int flags = data.toUInt(0, 3, true);
           flags & 1) {
          long long o = data.toLongLong(7U);
          if(o > offset)
            o += delta;
          file->seek(atom->offset() + 16);
          file->writeBlock(ByteVector::fromLongLong(o));
        }
      }
    }
  }

  // -- Duration reading -----------------------------------------------------

  //! Movie-level header info from mvhd.
  struct MovieInfo {
    unsigned int timescale = 0;
    unsigned int duration = 0;   // in mvhd timescale units
    long long durationMs = 0;    // converted to milliseconds
  };

  //! Reads movie-level info from mvhd.
  MovieInfo readMovieInfo(TagLib::File *file, const MP4::Atoms *atoms)
  {
    MovieInfo info;
    MP4::Atom *moov = atoms->find("moov");
    if(!moov)
      return info;

    const MP4::Atom *mvhd = moov->find("mvhd");
    if(!mvhd)
      return info;

    file->seek(mvhd->offset());
    ByteVector data = file->readBlock(mvhd->length());
    if(data.size() < 8 + 4)
      return info;

    const auto version = static_cast<unsigned char>(data[8]);
    long long timescale, duration;
    if(version == 1 && data.size() >= 8 + 28) {
      timescale = data.toUInt(28U);
      duration  = data.toLongLong(32U);
    }
    else if(data.size() >= 8 + 16 + 4) {
      timescale = data.toUInt(20U);
      duration  = data.toUInt(24U);
    }
    else {
      return info;
    }

    if(timescale > 0 && duration > 0) {
      info.timescale = static_cast<unsigned int>(timescale);
      info.duration = static_cast<unsigned int>(duration);
      info.durationMs = static_cast<long long>(
        static_cast<double>(duration) * 1000.0 / static_cast<double>(timescale) + 0.5);
    }
    return info;
  }

  // -- Audio track helpers --------------------------------------------------

  struct TrackInfo {
    MP4::Atom *trak = nullptr;
    unsigned int trackId = 0;
  };

  //! Finds the first audio track (hdlr handler_type == "soun").
  TrackInfo findAudioTrack(TagLib::File *file, const MP4::Atoms *atoms)
  {
    TrackInfo info;
    const MP4::Atom *moov = atoms->find("moov");
    if(!moov)
      return info;

    const MP4::AtomList trakList = moov->findall("trak");
    for(const auto &trak : trakList) {
      const MP4::Atom *hdlr = trak->find("mdia", "hdlr");
      if(!hdlr)
        continue;
      file->seek(hdlr->offset());
      // handler_type is at offset 16 from atom start (8 header + 4 version/flags + 4 pre_defined)
      if(ByteVector data = file->readBlock(hdlr->length());
         data.containsAt("soun", 16)) {
        // Read track_id from tkhd
        const MP4::Atom *tkhd = trak->find("tkhd");
        if(!tkhd)
          continue;

        file->seek(tkhd->offset());
        ByteVector tkhdData = file->readBlock(tkhd->length());
        if(tkhdData.size() < 9)
          continue;

        const auto version = static_cast<unsigned char>(tkhdData[8]);
        if(version == 1) {
          if(tkhdData.size() < 8 + 20 + 4)
            continue;
          info.trackId = tkhdData.toUInt(28U);
        }
        else {
          if(tkhdData.size() < 8 + 12 + 4)
            continue;
          info.trackId = tkhdData.toUInt(20U);
        }

        info.trak = trak;
        return info;
      }
    }
    return info;
  }

  //! Reads the next_track_ID from mvhd.
  unsigned int getNextTrackId(TagLib::File *file, const MP4::Atoms *atoms)
  {
    MP4::Atom *moov = atoms->find("moov");
    if(!moov) return 0;

    const MP4::Atom *mvhd = moov->find("mvhd");
    if(!mvhd) return 0;

    file->seek(mvhd->offset());
    ByteVector data = file->readBlock(mvhd->length());
    if(data.size() < 9)
      return 0;
    const auto version = static_cast<unsigned char>(data[8]);

    // next_track_ID is the last 4 bytes of mvhd
    // version 0: header(8) + version/flags(4) + creation(4) + modification(4)
    //            + timescale(4) + duration(4) + ... total fixed = 108 bytes
    // version 1: header(8) + version/flags(4) + creation(8) + modification(8)
    //            + timescale(4) + duration(8) + ... total fixed = 120 bytes
    if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4;
       data.size() >= nextTrackIdOffset + 4)
      return data.toUInt(nextTrackIdOffset);

    return 0;
  }

  //! Writes next_track_ID in mvhd.
  void setNextTrackId(TagLib::File *file, const MP4::Atoms *atoms, unsigned int newId)
  {
    MP4::Atom *moov = atoms->find("moov");
    if(!moov) return;

    const MP4::Atom *mvhd = moov->find("mvhd");
    if(!mvhd) return;

    file->seek(mvhd->offset());
    ByteVector data = file->readBlock(mvhd->length());
    if(data.size() < 9)
      return;
    const auto version = static_cast<unsigned char>(data[8]);

    if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4;
       data.size() >= nextTrackIdOffset + 4) {
      file->seek(mvhd->offset() + nextTrackIdOffset);
      file->writeBlock(ByteVector::fromUInt(newId));
    }
  }

  // -- Chapter track finder -------------------------------------------------

  //! Finds an existing chapter track by scanning for tref/chap in the audio track.
  //! tref is NOT in TagLib's container list, so we read it manually.
  MP4::Atom *findChapterTrak(TagLib::File *file, const MP4::Atoms *atoms,
                             const MP4::Atom *audioTrak)
  {
    if(!audioTrak)
      return nullptr;

    const MP4::Atom *moov = atoms->find("moov");
    if(!moov)
      return nullptr;

    for(const auto &child : audioTrak->children()) {
      if(child->name() == "tref") {
        file->seek(child->offset() + 8);
        const offset_t trefEnd = child->offset() + child->length();

        while(file->tell() + 8 <= trefEnd) {
          const offset_t boxStart = file->tell();
          ByteVector header = file->readBlock(8);
          if(header.size() < 8)
            break;

          const unsigned int boxSize = header.toUInt();
          if(boxSize < 8)
            break;
          if(static_cast<offset_t>(boxSize) > trefEnd - boxStart)
            break;

          if(ByteVector boxName = header.mid(4, 4);
             boxName == "chap" && boxSize >= 12) {
            ByteVector refData = file->readBlock(boxSize - 8);
            const unsigned int refTrackId = refData.toUInt();

            const MP4::AtomList allTraks = moov->findall("trak");

            for(const auto &t : allTraks) {
              const MP4::Atom *tkhd = t->find("tkhd");
              if(!tkhd)
                continue;

              file->seek(tkhd->offset());
              ByteVector tkhdData = file->readBlock(tkhd->length());
              if(tkhdData.size() < 24)
                continue;

              const auto version = static_cast<unsigned char>(tkhdData[8]);
              unsigned int tid;
              if(version == 1 && tkhdData.size() >= 32) {
                tid = tkhdData.toUInt(28U);
              }
              else {
                tid = tkhdData.toUInt(20U);
              }

              if(tid == refTrackId)
                return t;
            }
          }

          file->seek(boxStart + boxSize);
        }
      }
    }

    return nullptr;
  }

  // -- Text sample building -------------------------------------------------

  //! Size of the 'encd' (encoding) atom appended to each text sample.
  //! encd declares UTF-8 encoding: size(4) + "encd"(4) + 0x0100(4) = 12 bytes.
  constexpr unsigned int encdAtomSize = 12;

  //! Builds a single text sample: 2-byte big-endian length + UTF-8 text + encd atom.
  ByteVector buildTextSample(const String &title)
  {
    const ByteVector utf8 = title.data(String::UTF8);
    const unsigned int textLen = utf8.size();

    ByteVector sample;
    sample.append(ByteVector::fromShort(static_cast<short>(textLen)));
    if(textLen > 0)
      sample.append(utf8);

    // Append encd atom (encoding declaration: UTF-8) immediately after text.
    // No padding -- AVFoundation expects encd to follow directly after the text.
    ByteVector encdData;
    encdData.append(ByteVector::fromShort(0));  // padding
    encdData.append(ByteVector::fromShort(0x0100)); // UTF-8 encoding
    sample.append(renderAtom("encd", encdData));

    return sample;
  }

  //! Calculate the actual size of each chapter text sample.
  //! Each sample is: 2-byte length prefix + UTF-8 text + 12-byte encd atom.
  //! Returns a vector of per-sample sizes (no padding, sizes may differ).
  std::vector<unsigned int> calculateSampleSizes(const MP4::ChapterList &chapters)
  {
    std::vector<unsigned int> sizes;
    for(const auto &ch : chapters) {
      const unsigned int textLen = ch.title().data(String::UTF8).size();
      sizes.push_back(2 + textLen + encdAtomSize);
    }
    return sizes;
  }

  // -- stbl atom builders ---------------------------------------------------

  //! stsd: text sample description (required for QT text tracks)
  ByteVector buildStsd()
  {
    // QT text sample entry matching ffmpeg's chapter track output byte-for-byte.
    // Entry body (after 8-byte size+"text" header) is 51 bytes:
    //   reserved(6) + dref_index(2) + display_flags(4) + justification(4)
    //   + bgColor(6) + textBox(8) + fontID(2) + style_flags(1) + font_size(1)
    //   + text_color_RGBA(4) + ftab_atom(13)
    //
    // Hardcoded from ffmpeg's known-good output to avoid subtle field-size
    // mismatches with the under-documented QT text sample entry format.
    const unsigned char entryBody[] = {
      // reserved (6 bytes) + data reference index (2 bytes)
      0x00, 0x00, 0x00, 0x00, 0x00, 0x00,  0x00, 0x01,
      // display flags (4) = 1
      0x00, 0x00, 0x00, 0x01,
      // text justification (4) = 0
      0x00, 0x00, 0x00, 0x00,
      // background color RGB (6 bytes, QT text format: 2 bytes per channel)
      0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
      // default text box: top(2), left(2), bottom(2), right(2) = all zero
      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
      // font ID (2) = 1
      0x00, 0x01,
      // font style flags(1) + font size(1) + text color RGBA(4)
      0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
      // ftab atom: size(4)=13 + "ftab"(4) + entry_count(2)=1 + fontID(2)=1 + name_len(1)=0
      0x00, 0x00, 0x00, 0x0d,  0x66, 0x74, 0x61, 0x62,
      0x00, 0x01,  0x00, 0x01,  0x00
    };

    ByteVector sampleEntry;
    constexpr unsigned int entrySize = 8 + sizeof(entryBody);
    sampleEntry.append(ByteVector::fromUInt(entrySize));
    sampleEntry.append(ByteVector("text", 4));
    sampleEntry.append(ByteVector(reinterpret_cast<const char *>(entryBody),
                                  sizeof(entryBody)));

    ByteVector stsdPayload;
    stsdPayload.append(ByteVector::fromUInt(1));  // entry count
    stsdPayload.append(sampleEntry);

    return renderFullBox("stsd", 0, 0, stsdPayload);
  }

  //! stts: time-to-sample table.
  //! For N chapters, entry i has duration = chapter[i+1].start - chapter[i].start.
  //! The last chapter runs to the end of the file.
  ByteVector buildStts(const MP4::ChapterList &chapters, unsigned int timescale,
                       long long durationMs)
  {
    const unsigned int count = chapters.size();
    if(count == 0)
      return ByteVector();

    // Convert 100-ns units to timescale units
    auto toTimescale = [timescale](long long timeMs) -> unsigned int {
      return static_cast<unsigned int>(
        static_cast<double>(timeMs) * static_cast<double>(timescale) / 1000.0 + 0.5);
    };

    const auto totalDuration = static_cast<unsigned int>(
      static_cast<double>(durationMs) * static_cast<double>(timescale) / 1000.0 + 0.5);

    // Build per-sample durations
    std::vector<unsigned int> durations;
    auto it = chapters.begin();
    for(unsigned int i = 0; i < count; ++i, ++it) {
      auto next = it;
      ++next;
      const unsigned int startTs = toTimescale(it->startTime());
      unsigned int dur;
      if(next != chapters.end()) {
        const unsigned int nextTs = toTimescale(next->startTime());
        dur = nextTs - startTs;
      }
      else {
        // Last chapter runs to end of file
        dur = totalDuration > startTs ? totalDuration - startTs : 0;
      }
      durations.push_back(dur);
    }

    // One stts entry per sample (sampleCount=1 each), matching ffmpeg's output.
    // AVFoundation requires this layout rather than run-length encoding.
    ByteVector payload;
    payload.append(ByteVector::fromUInt(count));
    for(const auto d : durations) {
      payload.append(ByteVector::fromUInt(1));  // sample count
      payload.append(ByteVector::fromUInt(d));  // sample delta
    }

    return renderFullBox("stts", 0, 0, payload);
  }

  //! stsz: sample size table with per-sample entries (matching ffmpeg output).
  ByteVector buildStsz(const std::vector<unsigned int> &sampleSizes)
  {
    ByteVector payload;
    payload.append(ByteVector::fromUInt(0));  // default_sample_size = 0 (per-sample)
    payload.append(ByteVector::fromUInt(static_cast<unsigned int>(sampleSizes.size())));
    for(const auto sz : sampleSizes)
      payload.append(ByteVector::fromUInt(sz));
    return renderFullBox("stsz", 0, 0, payload);
  }

  //! stsc: sample-to-chunk table.  All samples in one chunk.
  ByteVector buildStsc(unsigned int sampleCount)
  {
    // All samples in a single chunk: one entry saying
    // "starting at chunk 1, N samples per chunk, description index 1"
    ByteVector payload;
    payload.append(ByteVector::fromUInt(1));           // entry count
    payload.append(ByteVector::fromUInt(1));           // first chunk
    payload.append(ByteVector::fromUInt(sampleCount)); // samples per chunk
    payload.append(ByteVector::fromUInt(1));           // sample description index

    return renderFullBox("stsc", 0, 0, payload);
  }

  //! stco: chunk offset table.  Single chunk offset pointing to the
  //! start of the contiguous text sample data.
  ByteVector buildStco(unsigned int offset)
  {
    ByteVector payload;
    payload.append(ByteVector::fromUInt(1));      // entry count = 1
    payload.append(ByteVector::fromUInt(offset)); // chunk offset
    return renderFullBox("stco", 0, 0, payload);
  }

  // -- Full trak builder ----------------------------------------------------

  //! Builds a complete chapter text trak atom.
  //! \a textDataOffset is where the text samples will start in the file.
  //! \a sampleSizes contains per-sample sizes for the stsz table.
  //! \a movieDuration is the movie-level duration in mvhd timescale units (for edts/elst).
  ByteVector buildChapterTrak(unsigned int trackId, unsigned int timescale,
                              long long durationMs,
                              const MP4::ChapterList &chapters,
                              const std::vector<unsigned int> &sampleSizes,
                              offset_t textDataOffset,
                              unsigned int movieDuration)
  {
    unsigned int count = chapters.size();
    auto totalDuration = static_cast<unsigned int>(
      static_cast<double>(durationMs) * static_cast<double>(timescale) / 1000.0 + 0.5);

    // Single chunk offset -- all samples are contiguous starting at textDataOffset
    auto chunkOffset = static_cast<unsigned int>(textDataOffset);

    // -- tkhd (track header) --
    // version 0: 8 header + 4 ver/flags + 4 creation + 4 modification
    //          + 4 track_id + 4 reserved + 4 duration + 8 reserved
    //          + 2 layer + 2 alternate_group + 2 volume + 2 reserved
    //          + 36 matrix + 4 width + 4 height = 92 bytes total
    ByteVector tkhdData;
    tkhdData.append(ByteVector(4, '\0'));     // creation time
    tkhdData.append(ByteVector(4, '\0'));     // modification time
    tkhdData.append(ByteVector::fromUInt(trackId));
    tkhdData.append(ByteVector(4, '\0'));     // reserved
    // Duration in mvhd timescale.
    tkhdData.append(ByteVector::fromUInt(totalDuration));
    tkhdData.append(ByteVector(8, '\0'));     // reserved
    tkhdData.append(ByteVector::fromShort(0)); // layer
    tkhdData.append(ByteVector::fromShort(0)); // alternate_group
    tkhdData.append(ByteVector::fromShort(0)); // volume (0 for text)
    tkhdData.append(ByteVector::fromShort(0)); // reserved
    // Identity matrix (3x3 fixed point)
    tkhdData.append(ByteVector::fromUInt(0x00010000)); // a = 1.0
    tkhdData.append(ByteVector(4, '\0'));               // b
    tkhdData.append(ByteVector(4, '\0'));               // u
    tkhdData.append(ByteVector(4, '\0'));               // c
    tkhdData.append(ByteVector::fromUInt(0x00010000));  // d = 1.0
    tkhdData.append(ByteVector(4, '\0'));               // v
    tkhdData.append(ByteVector(4, '\0'));               // x
    tkhdData.append(ByteVector(4, '\0'));               // y
    tkhdData.append(ByteVector::fromUInt(0x40000000));  // w = 1.0
    tkhdData.append(ByteVector::fromUInt(0)); // width
    tkhdData.append(ByteVector::fromUInt(0)); // height

    // flags = 0x02: track_in_movie only (matches ffmpeg's chapter track output).
    // Chapter tracks are NOT track_enabled(1) -- they are disabled but present in movie.
    ByteVector tkhd = renderFullBox("tkhd", 0, 0x02, tkhdData);

    // -- mdhd (media header) --
    ByteVector mdhdData;
    mdhdData.append(ByteVector(4, '\0'));     // creation time
    mdhdData.append(ByteVector(4, '\0'));     // modification time
    mdhdData.append(ByteVector::fromUInt(timescale));
    mdhdData.append(ByteVector::fromUInt(totalDuration));
    // language: 0x0000 (matches ffmpeg chapter track output)
    mdhdData.append(ByteVector::fromShort(0));
    mdhdData.append(ByteVector::fromShort(0)); // pre_defined

    ByteVector mdhd = renderFullBox("mdhd", 0, 0, mdhdData);

    // -- hdlr (handler reference) --
    ByteVector hdlrData;
    hdlrData.append(ByteVector(4, '\0'));              // pre_defined
    hdlrData.append(ByteVector("text", 4));            // handler_type
    hdlrData.append(ByteVector(12, '\0'));              // reserved
    // name: null-terminated "Chapter" string
    hdlrData.append(ByteVector("Chapter", 7));
    hdlrData.append(static_cast<char>(0));

    ByteVector hdlr = renderFullBox("hdlr", 0, 0, hdlrData);

    // -- gmhd (base media information header) --
    // QT text/chapter tracks use gmhd with gmin + text children.

    // gmin: graphicsMode(2) + opcolor(6) + balance(2) + reserved(2)
    ByteVector gminData;
    gminData.append(ByteVector::fromShort(0x0040)); // graphicsMode = ditherCopy (0x40)
    gminData.append(ByteVector("\x80\x00\x80\x00\x80\x00", 6)); // opcolor (gray)
    gminData.append(ByteVector::fromShort(0));      // balance
    gminData.append(ByteVector::fromShort(0));      // reserved
    ByteVector gmin = renderFullBox("gmin", 0, 0, gminData);

    // text media information atom: matrix(36) + ... = 36 bytes of data
    ByteVector textInfoData;
    textInfoData.append(ByteVector::fromShort(1));   // 0x0001
    textInfoData.append(ByteVector(14, '\0'));        // reserved
    textInfoData.append(ByteVector::fromShort(1));   // 0x0001
    textInfoData.append(ByteVector(14, '\0'));        // reserved
    textInfoData.append(ByteVector::fromUInt(0x40000000)); // 1.0 fixed point
    ByteVector textInfo = renderAtom("text", textInfoData);

    ByteVector gmhdContent;
    gmhdContent.append(gmin);
    gmhdContent.append(textInfo);
    ByteVector gmhd = renderAtom("gmhd", gmhdContent);

    // -- dinf / dref (data reference) --
    ByteVector drefEntry;
    // "url " self-reference entry (flag 1 = data is in this file)
    drefEntry = renderFullBox("url ", 0, 1, ByteVector());

    ByteVector drefData;
    drefData.append(ByteVector::fromUInt(1));  // entry count
    drefData.append(drefEntry);
    ByteVector dref = renderFullBox("dref", 0, 0, drefData);
    ByteVector dinf = renderAtom("dinf", dref);

    // -- stbl (sample table) --
    ByteVector stsd = buildStsd();
    ByteVector stts = buildStts(chapters, timescale, durationMs);
    ByteVector stsz = buildStsz(sampleSizes);
    ByteVector stsc = buildStsc(count);
    ByteVector stco = buildStco(chunkOffset);

    ByteVector stblContent;
    stblContent.append(stsd);
    stblContent.append(stts);
    stblContent.append(stsz);
    stblContent.append(stsc);
    stblContent.append(stco);
    ByteVector stbl = renderAtom("stbl", stblContent);

    // -- minf (media information) --
    ByteVector minfContent;
    minfContent.append(gmhd);
    minfContent.append(dinf);
    minfContent.append(stbl);
    ByteVector minf = renderAtom("minf", minfContent);

    // -- mdia (media) --
    ByteVector mdiaContent;
    mdiaContent.append(mdhd);
    mdiaContent.append(hdlr);
    mdiaContent.append(minf);
    ByteVector mdia = renderAtom("mdia", mdiaContent);

    // -- edts / elst (edit list) --
    // AVFoundation requires an edit list for the chapter track.
    // Single entry: play the whole media from time 0, at normal rate.
    ByteVector elstData;
    elstData.append(ByteVector::fromUInt(1));            // entry count
    elstData.append(ByteVector::fromUInt(movieDuration)); // segment duration (mvhd timescale)
    elstData.append(ByteVector::fromUInt(0));             // media time = 0
    elstData.append(ByteVector::fromUInt(0x00010000));    // media rate = 1.0 (fixed point)
    ByteVector elst = renderFullBox("elst", 0, 0, elstData);
    ByteVector edts = renderAtom("edts", elst);

    // -- trak --
    ByteVector trakContent;
    trakContent.append(tkhd);
    trakContent.append(edts);
    trakContent.append(mdia);
    ByteVector trak = renderAtom("trak", trakContent);

    return trak;
  }

  // -- tref / chap builder --------------------------------------------------

  //! Builds a chap reference box pointing at the given track ID.
  ByteVector buildChap(unsigned int chapterTrackId)
  {
    ByteVector chapData;
    chapData.append(ByteVector::fromUInt(chapterTrackId));
    return renderAtom("chap", chapData);
  }

  //! Builds a tref atom containing a chap reference to the given track ID.
  ByteVector buildTref(unsigned int chapterTrackId)
  {
    return renderAtom("tref", buildChap(chapterTrackId));
  }

  //! The track's tref atom, or null when it has none.
  //!
  //! A trak carries at most one tref; a second one makes AVFoundation discard the
  //! whole track, so a chapter reference has to join the existing atom rather than
  //! bring its own.
  MP4::Atom *findTrefAtom(const MP4::Atom *trak)
  {
    if(!trak)
      return nullptr;

    for(const auto &child : trak->children()) {
      if(child->name() == "tref")
        return child;
    }
    return nullptr;
  }

  //! Locates a reference box of the given type inside a tref.
  //!
  //! \return true when found, setting \a boxOffset and \a boxLength.
  bool findTrefEntry(TagLib::File *file, const MP4::Atom *tref,
                     const ByteVector &type, offset_t &boxOffset,
                     offset_t &boxLength)
  {
    if(!tref)
      return false;

    const offset_t trefEnd = tref->offset() + tref->length();
    file->seek(tref->offset() + 8);

    while(file->tell() + 8 <= trefEnd) {
      const offset_t boxStart = file->tell();
      const ByteVector header = file->readBlock(8);
      if(header.size() < 8)
        break;

      const unsigned int boxSize = header.toUInt();
      if(boxSize < 8)
        break;
      if(static_cast<offset_t>(boxSize) > trefEnd - boxStart)
        break;

      if(header.mid(4, 4) == type && boxSize >= 12) {
        boxOffset = boxStart;
        boxLength = static_cast<offset_t>(boxSize);
        return true;
      }
      file->seek(boxStart + boxSize);
    }
    return false;
  }

  // -- Reading helpers ------------------------------------------------------

  //! Reads chapter track duration info from the chapter trak's mdhd.
  struct ChapterTrackInfo {
    unsigned int timescale = 0;
    unsigned int totalDuration = 0;
  };

  ChapterTrackInfo readChapterTrackInfo(TagLib::File *file, MP4::Atom *chapterTrak)
  {
    ChapterTrackInfo info;

    const MP4::Atom *mdhd = chapterTrak->find("mdia", "mdhd");
    if(!mdhd)
      return info;

    file->seek(mdhd->offset());
    ByteVector data = file->readBlock(mdhd->length());
    if(data.size() < 8 + 4)
      return info;

    if(const auto version = static_cast<unsigned char>(data[8]);
       version == 1 && data.size() >= 40) {
      // v1 mdhd: header(8) + ver/flags(4) + creation(8) + modification(8)
      //          + timescale(4)@28 + duration(8)@32 + lang(2) + pre(2) = 44
      info.timescale = data.toUInt(28U);
      info.totalDuration = static_cast<unsigned int>(data.toLongLong(32U));
    }
    else if(version == 0 && data.size() >= 28) {
      // v0 mdhd: header(8) + ver/flags(4) + creation(4) + modification(4)
      //          + timescale(4)@20 + duration(4)@24 + lang(2) + pre(2) = 32
      info.timescale = data.toUInt(20U);
      info.totalDuration = data.toUInt(24U);
    }
    return info;
  }

  //! Reads stts entries from the chapter track.
  struct SttsEntry {
    unsigned int sampleCount;
    unsigned int sampleDelta;
  };

  std::vector<SttsEntry> readStts(TagLib::File *file, MP4::Atom *chapterTrak)
  {
    std::vector<SttsEntry> entries;
    const MP4::Atom *stts = chapterTrak->find("mdia", "minf", "stbl", "stts");
    if(!stts || stts->length() < 12)
      return entries;

    file->seek(stts->offset() + 12);  // skip header(8) + version/flags(4)
    const ByteVector data = file->readBlock(stts->length() - 12);
    if(data.size() < 4)
      return entries;

    const unsigned int count = data.toUInt();
    unsigned int pos = 4;
    for(unsigned int i = 0; i < count && pos + 8 <= data.size(); ++i) {
      SttsEntry e;
      e.sampleCount = data.toUInt(pos);
      e.sampleDelta = data.toUInt(pos + 4);
      entries.push_back(e);
      pos += 8;
    }
    return entries;
  }

  //! Reads chunk offsets from stco.
  std::vector<unsigned int> readStco(TagLib::File *file, MP4::Atom *chapterTrak)
  {
    std::vector<unsigned int> offsets;
    const MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco");
    if(!stco || stco->length() < 12)
      return offsets;

    file->seek(stco->offset() + 12);
    const ByteVector data = file->readBlock(stco->length() - 12);
    if(data.size() < 4)
      return offsets;

    const unsigned int count = data.toUInt();
    unsigned int pos = 4;
    for(unsigned int i = 0; i < count && pos + 4 <= data.size(); ++i) {
      offsets.push_back(data.toUInt(pos));
      pos += 4;
    }
    return offsets;
  }

  //! Reads sample sizes.  Returns (defaultSize, perSampleSizes).
  //! If defaultSize > 0, perSampleSizes is empty.
  struct SampleSizeInfo {
    unsigned int defaultSize = 0;
    unsigned int sampleCount = 0;
    std::vector<unsigned int> perSampleSizes;
  };

  SampleSizeInfo readStsz(TagLib::File *file, MP4::Atom *chapterTrak)
  {
    SampleSizeInfo info;
    const MP4::Atom *stsz = chapterTrak->find("mdia", "minf", "stbl", "stsz");
    if(!stsz || stsz->length() < 12)
      return info;

    file->seek(stsz->offset() + 12);
    const ByteVector data = file->readBlock(stsz->length() - 12);
    if(data.size() < 8)
      return info;

    info.defaultSize = data.toUInt();
    info.sampleCount = data.toUInt(4U);

    if(info.defaultSize == 0) {
      unsigned int pos = 8;
      for(unsigned int i = 0; i < info.sampleCount && pos + 4 <= data.size(); ++i) {
        info.perSampleSizes.push_back(data.toUInt(pos));
        pos += 4;
      }
    }
    return info;
  }

  //! Resolves chunk-level offsets (stco) into per-sample file offsets
  //! using the stsc (sample-to-chunk) table and sample sizes from stsz.
  //! This handles both single-chunk and multi-chunk layouts.
  std::vector<unsigned int> resolveSampleOffsets(TagLib::File *file,
                                                  MP4::Atom *chapterTrak,
                                                  const SampleSizeInfo &sizeInfo)
  {
    const std::vector<unsigned int> chunkOffsets = readStco(file, chapterTrak);
    if(chunkOffsets.empty())
      return {};
    // Sample count cannot be > file length / 4 as every sample consumes 4 bytes in stsz
    if(sizeInfo.sampleCount == 0 || sizeInfo.sampleCount > file->length() / 4)
      return {};

    // Read stsc entries
    struct StscEntry {
      unsigned int firstChunk;
      unsigned int samplesPerChunk;
      unsigned int descIndex;
    };
    std::vector<StscEntry> stscEntries;

    if(const MP4::Atom *stsc = chapterTrak->find("mdia", "minf", "stbl", "stsc");
       stsc && stsc->length() >= 12) {
      file->seek(stsc->offset() + 12);
      if(const ByteVector data = file->readBlock(stsc->length() - 12);
         data.size() >= 4) {
        const unsigned int entryCount = data.toUInt();
        unsigned int pos = 4;
        for(unsigned int i = 0; i < entryCount && pos + 12 <= data.size(); ++i) {
          StscEntry e;
          e.firstChunk = data.toUInt(pos);
          e.samplesPerChunk = data.toUInt(pos + 4);
          e.descIndex = data.toUInt(pos + 8);
          stscEntries.push_back(e);
          pos += 12;
        }
      }
    }

    // Default: 1 sample per chunk if no stsc
    if(stscEntries.empty()) {
      stscEntries.push_back({1, 1, 1});
    }

    // Resolve per-sample offsets by walking chunks
    std::vector<unsigned int> sampleOffsets;
    const auto totalChunks = static_cast<unsigned int>(chunkOffsets.size());
    unsigned int sampleIndex = 0;
    std::size_t stscIndex = 0;

    for(unsigned int chunkIdx = 0; chunkIdx < totalChunks; ++chunkIdx) {
      // Find which stsc entry applies to this chunk (1-based)
      const unsigned int chunkNum = chunkIdx + 1;
      while(stscIndex + 1 < stscEntries.size() &&
            stscEntries[stscIndex + 1].firstChunk <= chunkNum)
        ++stscIndex;
      unsigned int samplesInChunk = stscEntries[stscIndex].samplesPerChunk;

      unsigned int offsetInChunk = 0;
      if(samplesInChunk > sizeInfo.sampleCount - sampleIndex)
        samplesInChunk = sizeInfo.sampleCount - sampleIndex;

      for(unsigned int s = 0; s < samplesInChunk; ++s) {
        sampleOffsets.push_back(chunkOffsets[chunkIdx] + offsetInChunk);

        // Advance by this sample's size
        unsigned int sz = sizeInfo.defaultSize;
        if(sz == 0 && sampleIndex < sizeInfo.perSampleSizes.size())
          sz = sizeInfo.perSampleSizes[sampleIndex];
        offsetInChunk += sz;
        sampleIndex++;
      }
    }

    return sampleOffsets;
  }

  //! Read a text sample at a given file offset.
  String readTextSample(TagLib::File *file, unsigned int offset, unsigned int maxSize)
  {
    file->seek(offset);
    const ByteVector data = file->readBlock(maxSize);
    if(data.size() < 2)
      return String();

    const unsigned int textLen = data.toUShort();
    if(textLen == 0 || textLen + 2 > data.size())
      return String();

    return String(data.mid(2, textLen), String::UTF8);
  }

  bool sampleFits(TagLib::File *file, const std::vector<unsigned int> &offsets,
                  unsigned int sampleIndex, unsigned int sampleSize)
  {
    const offset_t offset = offsets[sampleIndex];
    const offset_t fileLength = file->length();
    if(offset < 0 || offset > fileLength ||
       static_cast<offset_t>(sampleSize) > fileLength - offset)
      return false;

    if(sampleIndex + 1 < offsets.size()) {
      const offset_t nextOffset = offsets[sampleIndex + 1];
      if(nextOffset <= offset ||
         static_cast<offset_t>(sampleSize) > nextOffset - offset)
        return false;
    }

    return true;
  }

  // -- Remove helpers -------------------------------------------------------

  //! Removes the tref atom from the audio track.
  //! Updates trak size, parent sizes, and chunk offsets.
  //! audioTrak's in-memory children list is NOT modified (caller re-parses if needed).
  //! Removes the chapter reference from the audio track.
  //!
  //! The chap box can share the track's single tref with references to other tracks
  //! -- a timecode track, for instance -- so only chap is removed in that case.
  //! The tref itself goes only when chap was its sole child.
  //!
  //! \a removedOffset and \a removedLength report what was cut, which the caller
  //! needs to locate atoms that sat after it.
  void removeAudioTref(TagLib::File *file, const MP4::Atoms *atoms,
                       const MP4::Atom *audioTrak, offset_t &removedOffset,
                       offset_t &removedLength)
  {
    removedOffset = -1;
    removedLength = 0;

    for(const auto &child : audioTrak->children()) {
      if(child->name() != "tref")
        continue;

      offset_t chapOff = 0;
      offset_t chapLen = 0;
      if(!findTrefEntry(file, child, "chap", chapOff, chapLen))
        continue;

      const offset_t trefOff = child->offset();
      const offset_t trefLen = child->length();

      // chap alone in the tref means the tref has nothing left to hold.
      const bool trefHoldsOnlyChap = (chapLen + 8 == trefLen);
      const offset_t cutOff = trefHoldsOnlyChap ? trefOff : chapOff;
      const offset_t cutLen = trefHoldsOnlyChap ? trefLen : chapLen;

      // The update below only supports ordinary 32-bit atom sizes. Verify
      // the on-disk headers before removing anything so malformed or
      // extended-size atoms cannot underflow the size fields.
      file->seek(trefOff);
      const unsigned int trefSize = file->readBlock(4).toUInt();
      file->seek(audioTrak->offset());
      const unsigned int trakSize = file->readBlock(4).toUInt();
      if(static_cast<offset_t>(trefSize) != trefLen
         || static_cast<offset_t>(trakSize) != audioTrak->length()
         || trefSize < cutLen || trakSize < cutLen)
        return;

      file->removeBlock(cutOff, cutLen);

      // Shrink the surviving tref. Its own offset precedes the cut, so it is
      // still where the atom tree says it is.
      if(!trefHoldsOnlyChap) {
        file->seek(trefOff);
        file->writeBlock(ByteVector::fromUInt(
          static_cast<unsigned int>(trefSize - cutLen)));
      }

      // Fix audio trak size on disk
      file->seek(audioTrak->offset());
      file->writeBlock(ByteVector::fromUInt(
        static_cast<unsigned int>(trakSize - cutLen)));

      const MP4::AtomList moovPath = atoms->path("moov");
      updateParentSizes(file, moovPath, -cutLen);
      updateChunkOffsets(file, atoms, -cutLen, cutOff);

      removedOffset = cutOff;
      removedLength = cutLen;
      return;
    }
  }

  //! Finds the top-level mdat atom that covers the given file offset.
  const MP4::Atom *findMdatContaining(const MP4::Atoms *atoms, offset_t fileOffset)
  {
    for(const auto *atom : atoms->atoms()) {
      if(atom->name() != "mdat")
        continue;
      const offset_t dataStart = atom->offset() + 8;
      const offset_t end = atom->offset() + atom->length();
      if(fileOffset >= dataStart && fileOffset < end)
        return atom;
    }
    return nullptr;
  }

  //! True if any stco/co64 entry in the atom tree points inside the given mdat range.
  //! Used to detect mdats that are shared with other tracks (audio data + chapter
  //! text co-located in a single mdat) so we never delete live track data.
  bool mdatIsUsedByAnyTrack(TagLib::File *file, const MP4::Atoms *atoms,
                            offset_t mdatStart, offset_t mdatSize)
  {
    const offset_t dataStart = mdatStart + 8;
    const offset_t dataEnd = mdatStart + mdatSize;

    const MP4::Atom *moov = atoms->find("moov");
    if(!moov)
      return false;

    for(const auto &stco : moov->findall("stco", true)) {
      if(stco->length() < 16)
        continue;
      file->seek(stco->offset() + 12);
      ByteVector data = file->readBlock(stco->length() - 12);
      if(data.size() < 4)
        continue;
      unsigned int count = data.toUInt();
      unsigned int pos = 4;
      const unsigned int maxPos = data.size() - 4;
      while(count-- && pos <= maxPos) {
        const auto o = static_cast<offset_t>(data.toUInt(pos));
        if(o >= dataStart && o < dataEnd)
          return true;
        pos += 4;
      }
    }

    for(const auto &co64 : moov->findall("co64", true)) {
      if(co64->length() < 20)
        continue;
      file->seek(co64->offset() + 12);
      ByteVector data = file->readBlock(co64->length() - 12);
      if(data.size() < 4)
        continue;
      unsigned int count = data.toUInt();
      unsigned int pos = 4;
      const unsigned int maxPos = data.size() - 8;
      while(count-- && pos <= maxPos) {
        const offset_t o = data.toLongLong(pos);
        if(o >= dataStart && o < dataEnd)
          return true;
        pos += 8;
      }
    }

    return false;
  }

  // Removes the QT chapter trak and the tref from the audio track, plus the
  // chapter text mdat if (and only if) no other track points into it.
  //
  // The chapter mdat identity is resolved by looking up which top-level mdat
  // contains the chapter track's first chunk offset.  After removing the trak
  // and tref, we re-parse and verify the mdat is not referenced by any other
  // track before deleting it.  Audiobook-style files commonly co-locate chapter
  // text inside the main audio mdat; in that case the mdat is shared and MUST
  // NOT be deleted -- the text bytes are left as dead space and the chapter
  // track alone is removed.
  void removeQTChapterTrack(TagLib::File *file, const MP4::Atoms *atoms,
                             MP4::Atom *moov, MP4::Atom *chapterTrak,
                             const MP4::Atom *audioTrak)
  {
    // Identify the chapter text mdat BEFORE removal (while stco is still valid).
    offset_t chapterMdatOffset = -1;
    offset_t chapterMdatSize = 0;
    {
      const std::vector<unsigned int> stco = readStco(file, chapterTrak);
      if(!stco.empty()) {
        if(const MP4::Atom *mdat = findMdatContaining(atoms,
                                                     static_cast<offset_t>(stco[0]))) {
          chapterMdatOffset = mdat->offset();
          chapterMdatSize = mdat->length();
        }
      }
    }

    // Remove chapter trak FIRST (higher offset in file).
    const offset_t chapterOff = chapterTrak->offset();
    const offset_t chapterLen = chapterTrak->length();

    // Remove from in-memory tree so updateChunkOffsets skips its stco.
    moov->removeChild(chapterTrak);
    delete chapterTrak;

    file->removeBlock(chapterOff, chapterLen);

    const MP4::AtomList moovPath = atoms->path("moov");
    updateParentSizes(file, moovPath, -chapterLen);
    updateChunkOffsets(file, atoms, -chapterLen, chapterOff);

    // Remove the chapter reference from the audio trak (lower offset, still valid
    // after chapter trak removal). Only the chap box goes when the tref is shared.
    offset_t trefOff = -1;
    offset_t trefLen = 0;
    removeAudioTref(file, atoms, audioTrak, trefOff, trefLen);

    // Decide whether the chapter mdat is safe to delete.
    if(chapterMdatOffset < 0)
      return;

    // Shift the original mdat offset by however much of the removed bytes
    // preceded it in the file.
    offset_t adjustedOffset = chapterMdatOffset;
    if(chapterMdatOffset > chapterOff)
      adjustedOffset -= chapterLen;
    if(trefOff >= 0 && chapterMdatOffset > trefOff)
      adjustedOffset -= trefLen;

    // Re-parse to verify the post-removal on-disk state matches expectations
    // and that no surviving track references this mdat's data range.
    const MP4::Atoms cleanAtoms(file);
    if(mdatIsUsedByAnyTrack(file, &cleanAtoms, adjustedOffset, chapterMdatSize))
      return;

    file->seek(adjustedOffset);
    const ByteVector header = file->readBlock(8);
    if(header.size() != 8 || header.mid(4, 4) != "mdat")
      return;
    if(static_cast<offset_t>(header.toUInt()) != chapterMdatSize)
      return;

    file->removeBlock(adjustedOffset, chapterMdatSize);
  }

}  // namespace

////////////////////////////////////////////////////////////////////////////////
// public members
////////////////////////////////////////////////////////////////////////////////

bool MP4::QtChapterList::read(TagLib::File *file)
{
  const Atoms atoms(file);
  modified = false;
  chapterList.clear();

  TrackInfo audio = findAudioTrack(file, &atoms);
  if(!audio.trak)
    return false;

  Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak);
  if(!chapterTrak)
    return false;

  ChapterTrackInfo trackInfo = readChapterTrackInfo(file, chapterTrak);
  if(trackInfo.timescale == 0)
    return false;

  const std::vector<SttsEntry> sttsEntries = readStts(file, chapterTrak);
  const SampleSizeInfo sizeInfo = readStsz(file, chapterTrak);
  const std::vector<unsigned int> offsets = resolveSampleOffsets(file, chapterTrak, sizeInfo);

  if(offsets.empty())
    return false;

  unsigned int sampleIndex = 0;
  long long currentTime = 0;

  for(const auto &entry : sttsEntries) {
    for(unsigned int s = 0; s < entry.sampleCount; ++s) {
      if(sampleIndex >= offsets.size())
        break;

      unsigned int sampleSize = sizeInfo.defaultSize;
      if(sampleSize == 0 && sampleIndex < sizeInfo.perSampleSizes.size())
        sampleSize = sizeInfo.perSampleSizes[sampleIndex];

      if(!sampleFits(file, offsets, sampleIndex, sampleSize))
        return false;

      String title = readTextSample(file, offsets[sampleIndex], sampleSize);

      const auto startTimeMs = static_cast<long long>(
        static_cast<double>(currentTime) * 1000.0 /
        static_cast<double>(trackInfo.timescale) + 0.5);

      chapterList.append(Chapter(title, startTimeMs));

      currentTime += entry.sampleDelta;
      sampleIndex++;
    }
  }

  // Strip a leading dummy chapter (empty title at time 0) that was inserted
  // during write to preserve non-zero first-chapter start times.
  if(chapterList.size() > 1) {
    if(const Chapter &first = chapterList.front();
       first.startTime() == 0 && first.title().isEmpty()) {
      chapterList.erase(chapterList.begin());
    }
  }

  return true;
}

bool MP4::QtChapterList::write(TagLib::File *file)
{
  // Writing an empty list is equivalent to removing the chapter track.
  if(chapterList.isEmpty())
    return remove(file);

  // ---- Phase 1: Parse and gather info ----

  Atoms atoms(file);
  Atom *moov = atoms.find("moov");
  if(!moov) {
    debug("MP4QTChapterList::write() -- No moov atom found");
    return false;
  }

  const MovieInfo movieInfo = readMovieInfo(file, &atoms);
  if(movieInfo.durationMs <= 0) {
    debug("MP4QTChapterList::write() -- Could not determine file duration");
    return false;
  }
  const long long durationMs = movieInfo.durationMs;

  TrackInfo audio = findAudioTrack(file, &atoms);
  if(!audio.trak) {
    debug("MP4QTChapterList::write() -- No audio track found");
    return false;
  }

  // ---- Phase 2: Remove existing chapter data (if any) ----

  // Pointer to the Atoms object we'll use for the insert phase.
  // Points to `atoms` for fresh writes, or `cleanAtoms` after cleanup.
  const Atoms *activeAtoms = &atoms;
  // Optional second parse -- only constructed when replacing existing chapters.
  std::unique_ptr<Atoms> cleanAtoms;

  if(Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak)) {
    removeQTChapterTrack(file, &atoms, moov, existingChapter, audio.trak);

    // Re-parse to get clean state after removals.
    cleanAtoms = std::make_unique<Atoms>(file);
    activeAtoms = cleanAtoms.get();

    moov = activeAtoms->find("moov");
    if(!moov) {
      debug("MP4QTChapterList::write() -- moov disappeared after cleanup");
      return false;
    }
    audio = findAudioTrack(file, activeAtoms);
    if(!audio.trak) {
      debug("MP4QTChapterList::write() -- No audio track after cleanup");
      return false;
    }
  }

  // ---- Phase 3: Build and insert new chapter data ----

  // QT chapter tracks always start at media time 0.  If the first chapter has a
  // non-zero start time, prepend a dummy chapter at time 0 with an empty title
  // so the absolute positions are preserved as stts durations.
  ChapterList workingChapters(chapterList);
  if(!workingChapters.isEmpty() && workingChapters.front().startTime() > 0) {
    workingChapters.prepend(Chapter(String(), 0));
  }

  const unsigned int nextId = getNextTrackId(file, activeAtoms);
  const unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1;
  constexpr unsigned int timescale = 1000;
  const std::vector<unsigned int> sampleSizes = calculateSampleSizes(workingChapters);

  // The chapter reference joins the audio track's existing tref when it has one --
  // a second tref in the same trak makes AVFoundation discard the entire track,
  // silently, while more permissive parsers still read it.
  const Atom *existingTref = findTrefAtom(audio.trak);
  const ByteVector refPayload = existingTref ? buildChap(chapterTrackId)
                                             : buildTref(chapterTrackId);
  const offset_t refInsertOffset = existingTref
    ? existingTref->offset() + existingTref->length()
    : audio.trak->offset() + audio.trak->length();

  // Two-pass build for chapter trak: first to measure size, then with correct stco offsets.
  const ByteVector trakMeasure = buildChapterTrak(
    chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, 0,
    movieInfo.duration);
  const auto totalInsert = static_cast<offset_t>(refPayload.size() + trakMeasure.size());
  // Text samples go inside an mdat atom at EOF.  stco offsets point past the 8-byte mdat header.
  const offset_t textDataOffset = file->length() + totalInsert + 8;

  // Build final trak with correct stco offsets pointing to where text data will land.
  const ByteVector trakAtom = buildChapterTrak(
    chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, textDataOffset,
    movieInfo.duration);

  // The chapter trak is a moov sibling placed after the audio trak; the reference
  // goes inside the audio trak, at or before that boundary. Insert the higher offset
  // first so the lower one is still where the atom tree says it is.
  const offset_t trakInsertOffset = audio.trak->offset() + audio.trak->length();

  // The atom tree is corrected once per insertion, because each shifts only what
  // follows it. A single combined delta would move the stco atoms lying between the
  // two -- the audio track's own -- further than the file actually moved them, and
  // the next seek would write chunk offsets into the following trak.
  file->insert(trakAtom, trakInsertOffset, 0);
  updateChunkOffsets(file, activeAtoms, static_cast<offset_t>(trakAtom.size()),
                     trakInsertOffset);

  file->insert(refPayload, refInsertOffset, 0);
  updateChunkOffsets(file, activeAtoms, static_cast<offset_t>(refPayload.size()),
                     refInsertOffset);

  // Grow the shared tref by the chap box it now carries.
  if(existingTref) {
    file->seek(existingTref->offset());
    const unsigned int trefSize = file->readBlock(4).toUInt();
    file->seek(existingTref->offset());
    file->writeBlock(ByteVector::fromUInt(trefSize + refPayload.size()));
  }

  // Fix audio trak size on disk -- only the reference goes inside
  file->seek(audio.trak->offset());
  const unsigned int audioTrakSize = file->readBlock(4).toUInt();
  const unsigned int newAudioTrakSize = audioTrakSize + refPayload.size();
  file->seek(audio.trak->offset());
  file->writeBlock(ByteVector::fromUInt(newAudioTrakSize));

  // Fix moov size -- both the reference and the chapter trak are inside moov
  const AtomList moovPath = activeAtoms->path("moov");
  updateParentSizes(file, moovPath, totalInsert);

  // ---- Phase 4: Append text samples in mdat at EOF ----

  ByteVector textSamples;
  for(const auto &ch : workingChapters) {
    textSamples.append(buildTextSample(ch.title()));
  }
  // Wrap text samples in an mdat atom so players can find them.
  const ByteVector mdatAtom = renderAtom("mdat", textSamples);

  file->seek(0, TagLib::File::End);
  file->writeBlock(mdatAtom);

  // ---- Phase 5: Update mvhd next_track_ID ----
  // mvhd precedes both insertions, so its offset is unchanged.

  if(const unsigned int currentNextId = getNextTrackId(file, activeAtoms);
     chapterTrackId >= currentNextId) {
    setNextTrackId(file, activeAtoms, chapterTrackId + 1);
  }

  modified = false;
  return true;
}

bool MP4::QtChapterList::remove(TagLib::File *file)
{
  Atoms atoms(file);
  chapterList.clear();
  modified = false;

  TrackInfo audio = findAudioTrack(file, &atoms);
  if(!audio.trak)
    return true;  // No audio track -- nothing to do

  Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak);
  if(!chapterTrak)
    return true;  // No chapter track -- nothing to do

  Atom *moov = atoms.find("moov");
  if(!moov)
    return false;

  removeQTChapterTrack(file, &atoms, moov, chapterTrak, audio.trak);
  return true;
}
