#!/bin/zsh
# Fetch, verify and build the LAME library that writes MP3 exports on the
# direct rail.
#
# See Docs/ADRs/0105-mp3-export-through-lame-on-the-direct-rail.md. LAME is
# Rooster's one LGPL component, so it ships as a separate dynamic library -
# `Contents/Frameworks/libmp3lame.0.dylib`, loaded at run time by
# Sources/Rooster/Features/Export/LAMELibrary.swift - and never as code
# compiled into Rooster. A user can swap in their own build by replacing the
# file and re-signing the app, since library validation opens only a library
# signed to match it. Nothing links against it, so no build setting names it
# and the App Store configurations carry no part of it: the embed phase in
# project.yml copies it into every configuration except those.
#
# LAME publishes source tarballs only, so this builds one. Pinned by version and
# SHA-256 together, the discipline Docs/ADRs/0006 set for whisper.cpp and
# Packaging/fetch_sparkle.sh follows: the provenance of what ships is a pinned
# archive and this reviewed script, and nothing binary is committed. The same
# archive and this same script are what rooster.cam/legal offers as LAME's
# source, which is the LGPL's condition for shipping the library at all.
#
# **No patch.** The archive is built exactly as LAME published it. A patch
# would be a modification the source offer must then carry; the flags below
# are all the build needs.
#
# This script does not sign. The embed phase, Packaging/package_app.sh and
# Packaging/notarize_app.sh each sign the copy they place in a bundle, with
# that bundle's identity, because the hardened runtime's library validation
# loads only a library signed by the same team as the app.
#
# Upgrading: change both values in the same commit, having re-derived the
# checksum with `shasum -a 256` over the downloaded archive, and replace the
# copy rooster.cam serves (Website/public/legal/source/) with the same archive.
# A test holds the served archive to this checksum.
set -euo pipefail

LAME_VERSION="4.0"
LAME_SHA256="3df5124d5ad3a98312ffd7ba6a9b36230e4f8a3e66d3ce0f425e336c32d216eb"

LAME_URL="https://downloads.sourceforge.net/project/lame/lame/${LAME_VERSION}/lame-${LAME_VERSION}.tar.gz"

PROJECT_DIR="${0:a:h:h}"
# Outside the checkout, because checkout's `git clean -ffdx` empties `.build`
# on every CI run and a library that never changes would otherwise be fetched
# and rebuilt per run. Keyed by the checksum as well as the version, so a
# re-pinned archive of the same version cannot be served the old build.
CACHE="${HOME}/Library/Caches/cam.rooster.build/lame/${LAME_VERSION}-${LAME_SHA256[1,8]}"
LIBRARY="${CACHE}/libmp3lame.0.dylib"
# Written last, and only after every check below has passed. The library is
# not the marker: an interrupted run can leave one that exists and is wrong.
STAMP="${CACHE}/.verified"
# The stable path each worktree reads - the tests, package_app.sh, the embed
# phase. A symlink into the shared cache, so neither the version nor the cache
# location is written anywhere else.
CURRENT="${PROJECT_DIR}/.build/lame/current"

# The checks a cached build must still pass, re-run on every cache hit. A
# check that runs once protects only the first run.
library_is_sound() {
  local library="$1"
  [[ "$(lipo -archs "$library" 2>/dev/null)" == "arm64" ]] || return 1
  [[ "$(otool -D "$library" | tail -n 1)" == "@rpath/libmp3lame.0.dylib" ]] || return 1
  # Everything it links must ship with macOS. A Homebrew library found by
  # configure would be a dependency no bundle carries.
  otool -L "$library" | tail -n +3 | awk '{print $1}' | while read -r dependency; do
    [[ "$dependency" == /usr/lib/* || "$dependency" == /System/Library/* ]] || return 1
  done
}

staged_and_sound() {
  [[ -f "$STAMP" ]] || return 1
  [[ "$(cat "$STAMP" 2>/dev/null)" == "$LAME_SHA256" ]] || return 1
  [[ -f "$LIBRARY" ]] || return 1
  library_is_sound "$LIBRARY"
}

link_current() {
  mkdir -p "${CURRENT:h}"
  # `-n` replaces an existing symlinked directory rather than writing inside it.
  ln -sfn "$CACHE" "$CURRENT"
}

already_built() {
  link_current
  echo "LAME ${LAME_VERSION} already built at ${LIBRARY}"
  exit 0
}

# The warm path takes no lock. Reading a finished cache is safe beside anyone,
# and it is what nearly every call is - each suite run, each package, CI's
# step - so a lock here would make a stage that takes milliseconds fail
# because another worktree happened to stage at the same moment.
staged_and_sound && already_built

mkdir -p "${CACHE:h}"
# `mkdir` is the lock because it is atomic and fails when the directory exists.
# The cache is shared by every worktree and by CI, which runs as the same user
# on this Mac, so the lock is too.
#
# **A second cold run waits rather than failing**, up to five minutes against
# a build that takes about twenty seconds, then finds the first run's result
# and leaves. The holder's process id is in the lock, so a lock left by a run
# killed outright - SIGKILL runs no trap - is recognised by its dead holder
# and taken over rather than wedging every build on the Mac until someone
# removes it by hand.
LOCK="${CACHE:h}/.lock"
waited=0
until mkdir "$LOCK" 2>/dev/null; do
  holder=$(cat "${LOCK}/pid" 2>/dev/null || true)
  # Re-read just before removing, so a second waiter that saw the same dead
  # holder does not remove the lock the first one has since taken.
  if [[ -n "$holder" ]] && ! kill -0 "$holder" 2>/dev/null \
    && [[ "$(cat "${LOCK}/pid" 2>/dev/null || true)" == "$holder" ]]; then
    echo "Taking over a lock left by build_lame.sh ${holder}, which is no longer running." >&2
    rm -rf "$LOCK"
    continue
  fi
  if (( waited >= 300 )); then
    echo "Another build_lame.sh has held ${LOCK} for five minutes." >&2
    echo "If nothing is running, remove it and try again." >&2
    exit 1
  fi
  sleep 2
  (( waited += 2 ))
done
print -r -- $$ > "${LOCK}/pid"

work=$(mktemp -d)
# EXIT alone does the cleaning. A string trap on INT or TERM runs and then
# lets the script carry on, so those exit instead, and the EXIT trap follows.
trap 'rm -rf "$work" "$LOCK"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

# Again under the lock: the run this one waited for may have just built it.
staged_and_sound && already_built

rm -rf "$CACHE"

echo "Fetching LAME ${LAME_VERSION}"
curl --fail --location --silent --show-error --output "${work}/lame.tar.gz" "$LAME_URL"

# Verified before anything is unpacked, so a substituted archive is never
# built. `shasum -c` rather than a string comparison, so the failure names the
# file and the expectation.
echo "${LAME_SHA256}  ${work}/lame.tar.gz" | shasum -a 256 -c - >/dev/null || {
  echo "LAME ${LAME_VERSION} failed its checksum." >&2
  echo "expected ${LAME_SHA256}" >&2
  echo "found    $(shasum -a 256 "${work}/lame.tar.gz" | cut -d' ' -f1)" >&2
  echo "Refusing to build it. Nothing falls back to an unverified archive." >&2
  exit 1
}

tar -xzf "${work}/lame.tar.gz" -C "$work"
source_dir="${work}/lame-${LAME_VERSION}"
[[ -f "${source_dir}/configure" ]] || {
  echo "The archive verified but holds no lame-${LAME_VERSION}/configure - the layout moved." >&2
  exit 1
}

# configure asks pkg-config for two things and wants neither: libsndfile for
# the command-line tool and libmpg123 for the decoder, both disabled below.
# This stand-in answers the version probe and finds no package, so the build
# needs no Homebrew and cannot pick up a library from it.
mkdir -p "${work}/bin"
cat > "${work}/bin/pkg-config" <<'STUB'
#!/bin/sh
case "$1" in
  --atleast-pkgconfig-version) exit 0 ;;
  --version) echo 0.29.2; exit 0 ;;
esac
exit 1
STUB
chmod +x "${work}/bin/pkg-config"

# arm64 alone: Rooster is an Apple Silicon application (ADR 0016).
# `--disable-decoder` keeps out mpglib, which is GPL rather than LGPL; the
# check after the build holds that. `ac_cv_prog_cc_c23=no` and
# `-Wno-implicit-function-declaration` are what LAME 4.0 needs from a current
# clang, as Homebrew's formula found.
flags="-arch arm64 -mmacosx-version-min=26.0"
(
  cd "$source_dir"
  PKG_CONFIG="${work}/bin/pkg-config" \
  ac_cv_prog_cc_c23=no \
  CFLAGS="${flags} -O2 -Wno-implicit-function-declaration" \
  LDFLAGS="${flags}" \
    ./configure --host=aarch64-apple-darwin --prefix="${work}/prefix" \
      --disable-decoder --disable-frontend --disable-static --enable-shared \
      --disable-dependency-tracking >"${work}/configure.log" 2>&1 \
    || { tail -n 20 "${work}/configure.log" >&2; exit 1; }
  make -j "$(sysctl -n hw.ncpu)" >"${work}/make.log" 2>&1 \
    || { tail -n 20 "${work}/make.log" >&2; exit 1; }
  make install >>"${work}/make.log" 2>&1 \
    || { tail -n 20 "${work}/make.log" >&2; exit 1; }
)

if grep -q '^#define HAVE_MPGLIB' "${source_dir}/config.h"; then
  echo "LAME was configured with mpglib, which is GPL. Refusing to stage it." >&2
  exit 1
fi

built="${work}/prefix/lib/libmp3lame.0.dylib"
[[ -f "$built" ]] || { echo "The build wrote no libmp3lame.0.dylib." >&2; exit 1; }
# Found through the app's `@rpath`, which reaches Contents/Frameworks, rather
# than the temporary prefix it was built in.
install_name_tool -id @rpath/libmp3lame.0.dylib "$built"

if ! library_is_sound "$built"; then
  echo "The built library is not arm64 alone, or links something macOS does not ship:" >&2
  lipo -archs "$built" >&2
  otool -L "$built" >&2
  exit 1
fi

mkdir -p "$CACHE"
cp "$built" "$LIBRARY"
cp "${source_dir}/COPYING" "${CACHE}/COPYING"
printf '%s' "$LAME_SHA256" > "$STAMP"
link_current

echo "LAME ${LAME_VERSION} built at ${LIBRARY}"
echo "this worktree reads ${CURRENT}"
