#!/bin/sh
#
# Spk-add - Install SliTaz packages. Read the README before adding or
# modifying any code in spk!
#
# Copyright (C) SliTaz GNU/Linux - BSD License
# Author: See AUTHORS files
#
. /usr/lib/slitaz/libspk.sh

#
# Functions
#

# Help and usage
usage() {
	name=$(basename $0)
	cat << EOT

$(boldify $(gettext "Usage:")) $name [packages|--options]

$(gettext "Install SliTaz Packages")

$(boldify $(gettext "Options:"))
  --forced    $(gettext "Force package reinstallation")
  --root=     $(gettext "Set the root file system path")
  --nodeps    $(gettext "Don't resolve packages dependencies")
  --asdep     $(gettext "Mark as auto-installed (dependency)")
  --newconf   $(gettext "Don't keep installed config files")
  --dry-run   $(gettext "Resolve the plan but do not modify the system")
  --explain   $(gettext "Print the plan as a list of shell commands (implies --dry-run)")
  --trame     $(gettext "JSON object on stdout (implies --dry-run)")
  --raw       $(gettext "Stream install milestones as flat TSV (for the TUI)")
  --debug     $(gettext "Display some useful debug information")

$(boldify $(gettext "Examples:"))
  $name package1 package2 packageN

EOT
	exit 0
}

# Log install messages
# Parameters: package
log_install() {
	local pkg=$1
	debug "log_install: $logdir/$pkg/install.log"
	mkdir -p $logdir/$pkg
	tee $logdir/$pkg/install.log
}

# Update system databases
update_databases() {
	if [ -f $root/usr/bin/update-desktop-database ] && [ -n "$updatedesktopdb" ]; then
		chroot "$root/" /usr/bin/update-desktop-database /usr/share/applications 2>/dev/null
	fi
	# Mimetypes
	if [ -f $root/usr/bin/update-mime-database ] && [ -n "$updatemimedb" ]; then
		chroot "$root/" /usr/bin/update-mime-database /usr/share/mime
	fi
	# Icons
	if [ -f $root/usr/bin/gtk-update-icon-cache ] && [ -n "$updateicondb" ]; then
		chroot "$root/" /usr/bin/gtk-update-icon-cache /usr/share/icons/hicolor
	fi
	# Glib schemas
	if [ -f $root/usr/bin/glib-compile-schemas ] && [ -n "$compile_schemas" ]; then
		chroot "$root/" /usr/bin/glib-compile-schemas /usr/share/glib-2.0/schemas
	fi
	# Kernel modules
	if [ -f $root/sbin/depmod ] && [ -n "$updatedepmod" ]; then
		chroot "$root/" /sbin/depmod -a
	fi
}

# This function installs a package in the rootfs.
# Parameters: package_name package_file
install_package() {
	local package_file=$1

	# Set by receipt: pre_depends() DEPENDS SELF_INSTALL CONFIG_FILES 
	# pre_install post_install()

	local package_name=$PACKAGE
	local package_dir="$installed/$package_name"
	# Was it already installed? (receipt present before we touch anything)
	# Decides whether the activity log says "Upgraded" or "Installed";
	# captured now, before the commit below overwrites the receipt.
	local was_installed=
	[ -f "$package_dir/receipt" ] && was_installed=yes
	mkdir -p $package_dir || exit 1

	# Transaction marker. Its presence means "install in progress".
	# spk-doctor --fix removes it on success-or-failure detection,
	# and uses it to find and clean up interrupted installs.
	# Receipt presence in $package_dir is the COMMIT marker; we copy
	# it ONLY after fs deployment succeeds. As long as .tx exists,
	# the package is considered half-installed.
	touch $package_dir/.tx 2>/dev/null

	# Run pre_depends from receipt if it exists
	if grep -q ^pre_depends $tmpdir/receipt; then
		pre_depends $root || { gettext "WARNING: pre_depends failed:"
			colorize 31 " $package_name"; newline; }
	fi

	# Resolve package deps. Disable with: --nodeps — tested FIRST so the
	# whole missing_deps scan (which prints "Missing:" and runs equivalent_pkg
	# per dep) is skipped, not run then discarded.
	local deps_rc=0
	if [ ! "$nodeps" ] && missing_deps $package_name $DEPENDS; then
		if [ "$confirm" ]; then
			gettext "Install missing dependencies"
			if ! confirm; then
				gettext "WARNING: Any dependencies installed"; newline
			else
				install_deps $package_name $DEPENDS || deps_rc=1
			fi
		else
			# Default is to install all missing deps
			install_deps $package_name $DEPENDS || deps_rc=1
		fi
	else
		newline
	fi
	# A dependency that failed to install must abort the parent BEFORE
	# anything is deployed: otherwise we'd register a package whose
	# dependencies are missing/broken. Only .tx exists in $package_dir
	# here, so rmdir cleans it without touching a populated dir.
	if [ "$deps_rc" -ne 0 ]; then
		gettext "ERROR: aborting"; colorize 31 " $package_name"
		gettext "(a dependency failed to install)"; newline
		rm -f "$package_dir/.tx"
		rmdir "$package_dir" 2>/dev/null
		return 1
	fi

	boldify $(gettext "Adding"; echo " $package_name")
	separator
	gettext "Copying package..."
	cp $package_file $tmpdir && status
	cd $tmpdir

	# NOTE: $installed.$SUM was previously written here, BEFORE deploy.
	# Moved past the deploy/commit point below so an aborted install
	# does not leave a stale checksum entry in the system database.

	# Remove receipt that will be overwritten by extraction
	[ -f $tmpdir/receipt ] && rm $tmpdir/receipt

	# Extract Package — bail on extraction failure (missing/broken cache,
	# corrupt cpio/lzma) instead of declaring "is installed" with no files.
	# rmdir (not rm -rf) cleans up the empty $package_dir created at L80
	# without ever touching a populated one (e.g. an upgrade in progress).
	#debug "extract_package $package_file $tmpdir"
	if ! extract_package $package_file $tmpdir; then
		gettext "ERROR: Failed to extract:"
		colorize 31 " $(basename $package_file)"
		newline
		# Only .tx is in $package_dir at this point — safe to clear.
		rm -f "$package_dir/.tx"
		rmdir "$package_dir" 2>/dev/null
		return 1
	fi

	# extract_package unpacked into a dir named after the package file
	# (basename without .tazpkg) — use the same name, arch suffix and
	# all, instead of reconstructing it (breaks on noarch '-any' pkgs).
	tmpdir="$tmpdir/$(package_name $package_file)"

	# Guard: the extracted tree must contain a fs/ directory, otherwise
	# 'cp -a fs/*' below silently does nothing and the package would be
	# registered as installed with zero deployed files.
	if [ ! -d "$tmpdir/fs" ]; then
		gettext "ERROR: Extracted package has no fs/ tree:"
		colorize 31 " $(basename $package_file)"
		newline
		rm -f "$package_dir/.tx"
		rmdir "$package_dir" 2>/dev/null
		return 1
	fi

	# ----- Integrity check (before deploying anything) ---------------
	# A cook bug can produce a .tazpkg that extracts cleanly but whose
	# payload is truncated/empty/corrupt. Verify the package's own
	# md5sum manifest against the extracted fs/ tree (manifest paths are
	# absolute, rooted at fs/ here). Symlinks: presence only (md5 would
	# follow the link). No/empty manifest -> fall back to ensuring fs/
	# isn't empty while files.list expects content. Refuse on any
	# mismatch and roll back the (not-yet-committed) install.
	gettext "Checking files integrity..."
	local intg=0
	if [ -s "$tmpdir/md5sum" ]; then
		local _sum _path _got
		# read -r: a path with a backslash must be taken literally, else read
		# eats it and "$tmpdir/fs$_path" points at the wrong (missing) file →
		# a bogus integrity failure that rolls back a perfectly good package.
		while read -r _sum _path; do
			[ -n "$_sum" ] || continue
			if [ -f "$tmpdir/fs$_path" ]; then
				_got=$($CHECKSUM "$tmpdir/fs$_path" | awk '{print $1}')
				[ "$_got" = "$_sum" ] || intg=$((intg + 1))
			elif [ -L "$tmpdir/fs$_path" ]; then
				:
			else
				intg=$((intg + 1))
			fi
		done < "$tmpdir/md5sum"
	elif [ -s "$tmpdir/files.list" ]; then
		[ -n "$(find "$tmpdir/fs" \( -type f -o -type l \) 2>/dev/null | head -n1)" ] \
			|| intg=$((intg + 1))
	fi
	( exit $intg ); status
	if [ "$intg" -ne 0 ]; then
		gettext "ERROR: integrity check failed:"
		colorize 31 " $package_name ($intg)"
		newline
		rm -f "$package_dir/.tx"
		rmdir "$package_dir" 2>/dev/null
		return 1
	fi
	# -----------------------------------------------------------------

	# Get files to remove if upgrading
	local files_to_remove
	if [ -f $package_dir/files.list ]; then
		# Read each path literally. A `for f in $(cat files.list)` lets the
		# shell glob-expand any entry holding */?/[ against the live FS: an
		# absolute glob like /usr/lib/lib*.so* matched 30 system libs on a
		# real root, which then flowed into files_to_remove and got rm'd.
		# while-read never globs and tolerates spaces.
		while IFS= read -r file
		do
			[ -n "$file" ] || continue
			grep -q "^$(echo $file | grepesc)$" $tmpdir/files.list && continue
			local modifiers=$(cat $package_dir/modifiers 2> /dev/null; \
				fgrep -sl $package_dir */modifiers | cut -d/ -f1)
			for i in modifiers; do
				grep -qs "^$(echo $file | grepesc)$" $i/files.list && continue 2
			done
			files_to_remove="$files_to_remove $file"
		done < $package_dir/files.list
	fi
	debug "file_to_remove: $files_to_remove"

	# Check files of the incoming package that already exist on disk for
	# conflicts with other installed packages. Read each path literally: the
	# old `for i in $(fgrep ...)` then `for file in $file_list` globbed twice
	# — any entry with */?/[ expanded against the live FS. while-read (plus
	# the explicit '[' skip kept from the old fgrep -v) never globs.
	debug "check modified files"
	while IFS= read -r file
	do
		[ -n "$file" ] || continue
		[ -e "${root}${file}" ] || continue
		[ -d "${root}${file}" ] && continue
		# Whole-line fixed-string match: the path is data, not a regex. The
		# old egrep "^$file$" let a '.', '+', '(' in the path act as a
		# metacharacter (and paths with '[' had to be skipped entirely, so
		# they were never conflict-checked). grep -xF matches the exact line
		# and handles every character literally.
		local filegrep=$(grep -xF -- "$file" $installed/*/files.list)
		if [ "$filegrep" ]; then
			local list=$(echo $filegrep | cut -d ":" -f 1)
			local count=0
			for pkg_file in $list; do
				local name=$(basename $(dirname $pkg_file))
				[  "$package_name" = "$name" ] && continue 2
				count=$(($count +1))
				[ "$count" = "1" ] && gettext "Modified package:"; \
					colorize 31 " $name"
				gettext "Overwriting file:"; echo " $file"
				# If confirm is set, ask to remove. Do we want that ?
				# If gawk is installed for example we will never remove Busybox
				# Use CONFLICTS receipt variable to avoid
				if [ "$confirm" ]; then
					echo -n "NOTE: confirm: spk-rm $name ???";
					if ! confirm; then
						exit 1
					fi
				fi
			done
		fi
	done < $tmpdir/files.list

	cd $tmpdir || exit 1
	# Stage files.list and description.txt early — they are needed for
	# rollback if deploy fails AND for the modifier checks above. The
	# receipt is intentionally NOT copied yet: its presence in
	# $package_dir is the atomic commit marker.
	cp files.list $package_dir || exit 1
	[ -f "description.txt" ] && cp description.txt $package_dir
	# Per-package md5sum (checksums of the package's own files) — kept in
	# the installed DB like tazpkg does, for integrity checks (spk-doctor,
	# recharge) and tazpkg<->spk interop.
	[ -f "md5sum" ] && cp md5sum $package_dir

	# Pre install commands — read from $tmpdir/receipt so a crash
	# here still leaves no receipt in $package_dir (= not installed).
	if grep -q ^pre_install $tmpdir/receipt; then
		pre_install $root || { gettext "WARNING: pre_install failed:"
			colorize 31 " $package_name"; newline; }
	fi

	# Handle Config Files set in receipt. Keep system configs: they can
	# be modified/customized by users, slitaz-config, etc.
	if [ "$CONFIG_FILES" ] && [ ! "$newconf" ]; then
		# wc -w, not -l: CONFIG_FILES is space-separated on one line after
		# echo, so -l always reported 1 config kept.
		ccf="$(echo $CONFIG_FILES | wc -w)"
		colorize 34 "$(gettext 'Keeping configuration files: '; echo $ccf)"
		for file in ${CONFIG_FILES}; do
			# Preserve the TARGET's existing config (under $root), never the
			# running host's: a --root install into a chroot must keep that
			# chroot's customized file. Testing/copying the bare $file read
			# the host FS and baked the host's config into the chroot. Copy
			# the current file over the package's default in the staging fs/
			# so the merge below leaves it untouched. (root="" for a normal
			# install, so this is identical there.)
			if [ -f "${root}${file}" ]; then
				gettext "Keeping:"; echo -n " $file"
				dir=$(dirname "$file")
				mkdir -p "fs/${dir}"
				cp -f "${root}${file}" "fs/${dir}/" 2>/dev/null
				status
				debug "cp -f ${root}${file} fs/${dir}/"
			fi
		done
		unset CONFIG_FILES
	fi
	
	# Merge package FS with $root
	nb=$(cat files.list | wc -l)
	gettext "Installing files:"; echo -n " $nb"
	cp -a fs/* $root/
	local cp_rc=$?
	status
	if [ "$cp_rc" -ne 0 ]; then
		gettext "ERROR: file deployment failed; aborting before commit"
		colorize 31 " ($package_name)"
		newline
		# .tx still in place → spk-doctor --fix recovery will roll
		# back the partial fs/ deploy from files.list.
		return 1
	fi

	# ----- Atomic commit ---------------------------------------------
	# From this point on, "package is installed" = "$package_dir/receipt
	# exists". We:
	#  1. copy receipt into $package_dir       (commit marker)
	#  2. register md5 in $installed.$SUM      (system DB update)
	#  3. drop the .tx in-progress marker      (no more rollback needed)
	# A crash between 1 and 3 leaves a (small) inconsistency that
	# spk-doctor --fix handles deterministically: receipt present →
	# rebuild .SUM line; .tx still present → just clear it.
	cp receipt $package_dir/ || {
		gettext "ERROR: failed to commit receipt; aborting"
		newline
		return 1
	}
	# Drop any prior installed.$SUM line for this package before appending
	# the fresh one, so upgrades leave exactly one line. The old
	# sed "/  $PACKAGE-$VERSION*/d" matched the NEW version, so on an upgrade
	# (bc 1.08.2 -> 1.09) it never matched the OLD "bc-1.08.2-x86_64.tazpkg"
	# line and both piled up — the stale-md5 drift spk-doctor now reports.
	# Match field 2 (the filename) by exact package name: "$PACKAGE-" as a
	# fixed-string prefix (index, so gtk+ is safe) with a version digit right
	# after, which tells bc- from bc-dev-. Any arch suffix is accepted.
	if [ -f "$installed.$SUM" ]; then
		awk -v p="$PACKAGE" '{
			n=$2
			if (index(n, p"-")==1 && substr(n, length(p)+2, 1) ~ /[0-9]/) next
			print
		}' "$installed.$SUM" > "$installed.$SUM.$$" \
			&& mv "$installed.$SUM.$$" "$installed.$SUM"
	fi
	# md5sum prefixes its output with "<hash>  <path>". We want the
	# basename only, so cd to the cache dir first instead of stripping
	# from the output (sed strip is fragile because the hash itself
	# may contain "/").
	(cd $(dirname $package_file) && \
		$CHECKSUM $(basename $package_file)) >> $installed.$SUM
	# Keep installed.info (the tazpkg-era index) in sync. spk drives its
	# own DB off the receipt dirs, but tazpkg / TazPanel / tazpkg-notify
	# read installed.info, so a package spk installs must be recorded
	# there too or the two DBs drift apart. Line = 9 tab fields, last is
	# the package md5 (same hash as installed.$SUM); sizes .0-stripped
	# like cook-pkgdb. spk-rm drops the matching line. Field-1 match via
	# awk (exact, so names with regex chars like gtk+ are safe).
	imd5=$($CHECKSUM "$package_file" 2>/dev/null | awk '{print $1}')
	[ -f $installed.info ] || : > $installed.info
	awk -F'\t' -v p="$PACKAGE" '$1!=p' $installed.info > $installed.info.$$ \
		&& mv $installed.info.$$ $installed.info
	printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
		"$PACKAGE" "${VERSION}${EXTRAVERSION}" "$CATEGORY" "$SHORT_DESC" \
		"$WEB_SITE" "$(echo $TAGS)" \
		"$(echo $PACKED_SIZE $UNPACKED_SIZE | sed 's|\.0||g')" \
		"$(echo $DEPENDS)" "$imd5" >> $installed.info
	rm -f $package_dir/.tx
	# Record how the package entered the system: pulled in as a
	# dependency (--asdep) leaves a .auto marker, an explicit request
	# clears it (promotion). 'spk-rm --autoremove' reaps auto packages
	# once nothing depends on them.
	if [ "$asdep" ]; then
		touch $package_dir/.auto 2>/dev/null
	else
		rm -f $package_dir/.auto
	fi
	# -----------------------------------------------------------------

	# Remove old config files
	if [ "$files_to_remove" ]; then
		gettext "Removing old files..."
		# noglob: files_to_remove holds literal paths; never let the shell
		# expand a */?/[ in one of them against the live FS before rm.
		set -f
		for file in $files_to_remove; do
			if [ "$verbose" ]; then
				gettext "Removing:"; echo " ${root}${file}"
			fi
			#remove_file ${root}${file}
			rm -f ${root}${file} 2>/dev/null
			rmdir ${root}${file} 2> /dev/null
		done
		set +f
		status
	fi
	cd - >/dev/null

	# Remove the temporary directory.
	if [ "$verbose" ]; then
		gettext "Removing all tmp files... "
		rm -rf $tmpdir && status
	else
		rm -rf $tmpdir
	fi

	# Post install commands.
	if grep -q ^post_install $package_dir/receipt; then
		post_install $root || { gettext "WARNING: post_install failed:"
			colorize 31 " $package_name"; newline; }
	fi

 	# Update-desktop-database if needed.
	if [ "$(fgrep .desktop $package_dir/files.list | fgrep /usr/share/applications/)" ]; then
		updatedesktopdb=yes
	fi
	# Update-mime-database if needed.
	if [ "$(fgrep /usr/share/mime $package_dir/files.list)" ]; then
		updatemimedb=yes
	fi
	# Update-icon-database
	if [ "$(fgrep /usr/share/icon/hicolor $package_dir/files.list)" ]; then
		updateicondb=yes
	fi
	# Compile glib schemas if needed.
	if [ "$(fgrep /usr/share/glib-2.0/schemas $package_dir/files.list)" ]; then
		compile_schemas=yes
	fi
	# Update depmod list
	if [ "$(fgrep /lib/modules $package_dir/files.list)" ]; then
		updatedepmod=yes
	fi
	# Record in the activity log ($activity) — the same channel spk-rm and
	# blocked use and that `spk activity` reads. Until now only removals
	# showed up; installs and upgrades were invisible. Covers spk-up too,
	# which upgrades by calling spk-add --forced.
	if [ "$was_installed" ]; then
		log "Upgraded package: $package_name"
	else
		log "Installed package: $package_name"
	fi

	separator
	echo -n "$package_name ${VERSION}${EXTRAVERSION} "
	gettext "is installed"; newline
	newline
}

# Install .tazpkg packages.
# Parameters: package_file
install_local() {
	local package_file="$1"
	if [ -f "$package_file" ]; then
		if [ $(dirname $package_file) = "." ]; then
			package_file=$(pwd)/$package_file
		fi
		debug "package file: $package_file"
	else
		gettext "Unable to find:"; echo " $package_file"
		exit 1
	fi

	# Reject a broken/empty .tazpkg up front (cook bug, truncated copy)
	# before reading its receipt or installing anything.
	check_valid_tazpkg "$package_file" || return 1

	# Get package name now to check if installed
	mkdir -p $tmpdir
	if ! extract_receipt $tmpdir $package_file; then
		gettext "ERROR: Cannot read receipt from:"
		colorize 31 " $package_file"
		newline
		return 1
	fi
	source $tmpdir/receipt
	[ "$forced" ] || check_installed $PACKAGE
	[ "$count" = "1" ] && newline
	install_package $package_file
	update_databases
}

# Download and install a package. TODO: Handle Undigest Mirrors
# Parameters: package_name
install_web() {
	local package_name="$1"

	# Check if get-Package
	#if mirrored_pkg get-$package_name; then
		#package_name="get-$package_name"
		#exec=true
	#fi

	# Check if package is mirrored
	mirrored_pkg $package_name
	if [ ! "$mirrored" ]; then
		gettext "Could not find package on mirror:"
		boldify " $package_name" && exit 1
	fi

	# Real on-mirror filename, arch suffix included (-x86_64, -any, …).
	local package_full=$(full_package ${package_name})

	# Lets fetch the package by download()
	[ "$count" = 1 ] && newline
	debug "spk-add package: $package_name"
	download "$package_full" $mirror
	cd $CACHE_DIR

	# Create package path early to avoid dependencies loop
	mkdir -p $tmpdir
	if ! extract_receipt $tmpdir "$CACHE_DIR/$package_full"; then
		gettext "ERROR: Cannot read receipt from:"
		colorize 31 " $CACHE_DIR/$package_full"
		newline
		return 1
	fi
	source $tmpdir/receipt

	install_package "$CACHE_DIR/$package_full"
	# Capture the install status before the post-steps clobber $?. Without
	# this, install_web returned unset_mirrored's status (always 0), so a
	# mid-install dependency abort (install_package -> return 1) was masked
	# and the caller — spk-up — counted a failed package as "handled".
	local iprc=$?

	#[ "$exec" ] && chroot $root/ $package_name
	[ "$exec" ] && $package_name $root
	update_databases
	unset_mirrored
	return $iprc
}

# Install all missing deps of a package.
# Usage: install_deps package DEPENDS
install_deps() {
	local package=$1
	shift
	local deps="$@"
	local rc=0
	for pkgorg in $deps; do
		local pkg=$(equivalent_pkg $pkgorg)
		# Treat a dep as present only if its receipt exists, not merely its
		# directory: an earlier interrupted install can leave a receipt-less
		# ghost dir, and a bare `[ -d ]` test would then skip the dep and let
		# the parent commit over a package that isn't really installed — the
		# exact hole the 1.5.1 dep-abort was meant to close.
		if ! is_package_installed "$pkg"; then
			if [ ! -f "$pkgsinfo" ]; then
				spk-up --list
			fi
			# --asdep: mark it auto-installed (autoremove candidate).
			spk-add $pkg --asdep
			# Verify the dependency actually committed (receipt present).
			# spk-add can fail mid-way (broken cache, extraction/integrity
			# error) and we must NOT let the parent install over a missing
			# dep — flag it so install_package() aborts.
			if ! is_package_installed "$pkg"; then
				gettext "ERROR: dependency failed to install:"
				colorize 31 " $pkg"; newline
				rc=1
			fi
		fi
	done
	return $rc
}

#
# Output mode + planner — used only for --trame / --dry-run / --explain.
# Human flow below stays untouched.
#

# --explain and --trame both imply --dry-run (never mutate).
[ "$explain" ] && dry_run=yes
[ "$trame" ] && dry_run=yes

json_escape() {
	printf '%s' "$1" | sed \
		-e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/	/\\t/g' \
		-e 's/\r/\\r/g' -e ':a;N;$!ba;s/\n/\\n/g'
}

# Whitespace-collapse a multi-line / multi-space string.
clean_ws() { echo $@; }

# Resolve the transitive set of packages that would be added.
# Stops at packages already installed (idempotence). Returns the
# full list, leftmost = top-level requested pkg.
# Usage: plan_install <pkg>
# Globals set: plan_full (list), plan_missing (list)
plan_install() {
	local root_pkg="$1"
	local queue="$root_pkg" seen="" cur info line deps dep
	plan_full=
	plan_missing=

	while [ -n "$queue" ]; do
		cur="${queue%% *}"
		queue="${queue#"$cur"}"
		queue="${queue# }"
		case " $seen " in *" $cur "*) continue ;; esac
		seen="$seen $cur"

		if is_package_installed "$cur"; then
			plan_full="$plan_full $cur"
			# Installed deps are pruned (no need to fetch). But on a forced
			# upgrade we MUST still walk the ROOT package's DEPENDS: its new
			# receipt can require a dependency the installed copy never had
			# (e.g. cairo-dev -> cairo-tools), and preflight must spot an
			# unavailable one now instead of aborting mid-install. The root
			# itself isn't added to plan_missing — it's present and fetchable.
			[ "$cur" = "$root_pkg" ] && [ -n "$forced" ] || continue
		else
			plan_full="$plan_full $cur"
			plan_missing="$plan_missing $cur"
		fi

		# Look up DEPENDS (col 8) from packages.info (extras first,
		# then official). mirrored_pkg sets $mirrored which we re-use
		# rather than greping twice.
		mirrored_pkg "$cur"
		[ "$mirrored" ] || continue
		deps=$(echo "$mirrored" | awk -F'	' '{print $8}')
		deps=$(clean_ws $deps)
		for dep in $deps; do
			dep=$(equivalent_pkg "$dep" 2>/dev/null || echo "$dep")
			[ -z "$dep" ] && continue
			queue="$queue $dep"
		done
		unset_mirrored
	done
	plan_full=$(clean_ws $plan_full)
	plan_missing=$(clean_ws $plan_missing)
}

# Pre-flight a named package: resolve its full dependency closure (via
# plan_install) and verify every still-missing piece is actually fetchable
# from a mirror. Echoes the unavailable packages (empty = all satisfiable)
# and leaves $plan_missing set for the caller. Read-only — lets spk-add
# refuse upfront instead of aborting half-way through a dependency tree.
preflight_deps() {
	plan_install "$1"
	local p missing=""
	for p in $plan_missing; do
		mirrored_pkg "$p"
		[ "$mirrored" ] || missing="$missing $p"
		unset_mirrored
	done
	echo $missing
}

# Emit --explain shell-cmd plan (one command per line on stdout).
emit_explain() {
	local pkgs="$1" p
	for p in $pkgs; do
		echo "spk-add $p --forced"
	done
}

# Emit one trame envelope for the whole spk-add call.
# Args: pkg action(installed|already-installed) plan_full plan_missing
emit_add_trame() {
	local pkg="$1" action="$2" full="$3" miss="$4"
	local changed=true
	[ "$action" = already-installed ] && changed=false
	local dry=true
	[ "$dry_run" ] || dry=false
	# Build JSON arrays from space-lists.
	local a_full a_miss first p
	a_full="["; first=yes
	for p in $full; do
		[ "$first" ] || a_full="${a_full},"; first=
		a_full="${a_full}\"$(json_escape "$p")\""
	done
	a_full="${a_full}]"
	a_miss="["; first=yes
	for p in $miss; do
		[ "$first" ] || a_miss="${a_miss},"; first=
		a_miss="${a_miss}\"$(json_escape "$p")\""
	done
	a_miss="${a_miss}]"
	printf '{"command":"add","package":"%s","action":"%s","changed":%s,"dry_run":%s,"deps_resolved":%s,"deps_to_install":%s}\n' \
		"$(json_escape "$pkg")" "$action" "$changed" "$dry" \
		"$a_full" "$a_miss"
}

#
# Commands and exit
#

case "$1" in
	""|*usage|*help) usage ;;
esac

# --trame / --dry-run / --explain: short-circuit BEFORE the human
# install path runs. Each pkg gets its own envelope (NDJSON-style for
# multi-pkg calls, single object for one pkg — consumers see a stream
# of complete JSON objects regardless).
if [ "$trame" ] || [ "$explain" ] || [ "$dry_run" ]; then
	for pkg in "$@"; do
		case "$pkg" in --*) continue ;; esac
		case "$pkg" in *.tazpkg|*.spk|*.deb|*.rpm)
			# Local-file mode planning is out of scope for now.
			echo "spk-add: --dry-run only supports named packages, not files: $pkg" >&2
			exit 4
		esac
		if is_package_installed "$pkg" && [ -z "$forced" ]; then
			[ "$trame" ]   && emit_add_trame "$pkg" already-installed "$pkg" ""
			[ "$explain" ] && echo "# $pkg: already installed (no-op)"
			[ -z "$trame" ] && [ -z "$explain" ] && \
				echo "$pkg: already installed"
			continue
		fi
		plan_install "$pkg"
		[ "$trame" ]   && emit_add_trame "$pkg" would-install "$plan_full" "$plan_missing"
		[ "$explain" ] && emit_explain "$plan_missing"
		[ -z "$trame" ] && [ -z "$explain" ] && {
			echo "Would install: $plan_missing"
		}
	done
	exit 0
fi

#
# --raw : stream a flat TSV of milestones, one line per step, for the TUI
# (and scripts). The real install runs UNCHANGED (install_web/install_local
# -> install_package); only their human output is redirected to the log so
# the TSV stream stays clean. Format: state<TAB>step<TAB>pkg<TAB>msg
#   state = step | info | ok | error | done
# Never touches install_package; just wraps the dispatch.
#
if [ "$raw" ]; then
	check_root
	mkdir -p "$logdir" 2>/dev/null
	r_ok=0; r_skip=0; r_fail=0
	for pkg in "$@"; do
		case "$pkg" in --*) continue ;; esac

		case "$pkg" in
		*.tazpkg|*.spk)
			printf 'step\tinstall\t%s\tinstalling local file\n' "$pkg"
			# Subshell: install_local/install_package may call exit on a
			# fatal error; contain it so the TSV stream still reaches 'done'.
			if ( install_local "$pkg" ) >>"$logdir/raw-install.log" 2>&1; then
				printf 'ok\tinstall\t%s\tinstalled\n' "$pkg"
				r_ok=$((r_ok + 1))
			else
				printf 'error\tinstall\t%s\tfailed (see log)\n' "$pkg"
				r_fail=$((r_fail + 1))
			fi
			continue ;;
		esac

		# Named package from a mirror.
		if is_package_installed "$pkg" && [ -z "$forced" ]; then
			printf 'ok\tskip\t%s\talready installed\n' "$pkg"
			r_skip=$((r_skip + 1)); continue
		fi

		printf 'step\tplan\t%s\tresolving dependencies\n' "$pkg"
		if [ -z "$nodeps" ]; then
			unavail=$(preflight_deps "$pkg")
			if [ "$unavail" ]; then
				printf 'error\tplan\t%s\tunavailable: %s\n' \
					"$pkg" "$unavail"
				r_fail=$((r_fail + 1)); continue
			fi
			[ "$plan_missing" ] && \
				printf 'info\tplan\t%s\tto install: %s\n' \
					"$pkg" "$plan_missing"
		fi

		printf 'step\tinstall\t%s\tdownloading and installing\n' "$pkg"
		# Subshell: install_web exits 1 when a package is absent from the
		# mirror (and install_package can exit too); contain it so the
		# stream still emits its 'done' summary line.
		( install_web "$pkg" ) >>"$logdir/raw-install.log" 2>&1
		if is_package_installed "$pkg"; then
			printf 'ok\tinstall\t%s\tinstalled\n' "$pkg"
			r_ok=$((r_ok + 1))
		else
			printf 'error\tinstall\t%s\tfailed (see log)\n' "$pkg"
			r_fail=$((r_fail + 1))
		fi
	done
	done_msg="$r_ok installed"
	[ "$r_skip" -gt 0 ] && done_msg="$done_msg, $r_skip skipped"
	done_msg="$done_msg, $r_fail failed"
	printf 'done\t-\t-\t%s\n' "$done_msg"
	exit 0
fi

#
# Handle packages: package package.tazpkg ... packageN packageN.tazpkg
#

: ${count=0}
check_root

# Tracks whether any requested package was refused/failed, so spk-add can
# exit non-zero. Callers (spk-up, scripts) rely on the status to tell a
# real install from a refusal — an exit 0 on failure made spk-up count
# unavailable-dependency packages as "handled".
addrc=0

for pkg in $@
do
	case "$pkg" in
		*.tazpkg|*.spk)
			count=$(($count + 1))
			# Capture install_local's real status, not log_install's (tee,
			# always 0): a broken local .tazpkg (bad integrity, extraction
			# failure) otherwise made spk-add exit 0 and callers count it as
			# handled. Same .webrc trick as the install_web path below; an
			# install_local that exit's mid-pipe leaves .webrc empty, which
			# reads as non-zero and still flags the failure.
			mkdir -p $logdir
			{ install_local $pkg; echo $? >"$logdir/.webrc.$$"; } | log_install $pkg
			[ "$(cat "$logdir/.webrc.$$" 2>/dev/null)" = 0 ] || addrc=1
			rm -f "$logdir/.webrc.$$" ;;
		*.deb|*.rpm)
			echo "TODO: spk-convert then install" ;;
		--*) continue ;;
		*)
			[ "$forced" ] || check_installed $pkg
			# Pre-flight the whole dependency closure: if any piece is
			# unavailable on the mirrors, refuse now rather than leave a
			# half-installed tree behind. Skipped with --nodeps.
			if [ -z "$nodeps" ]; then
				unavail=$(preflight_deps $pkg)
				if [ "$unavail" ]; then
					# Split the requested package (if it is itself missing
					# from every mirror) from its unavailable dependencies,
					# so the error names the real culprit instead of calling
					# the top-level package one of its own "dependencies".
					self_unavail=; deps_unavail=
					for u in $unavail; do
						if [ "$u" = "$pkg" ]; then self_unavail=yes
						else deps_unavail="$deps_unavail $u"; fi
					done
					gettext "ERROR: cannot install"; colorize 31 " $pkg"
					newline
					if [ "$self_unavail" ]; then
						gettext "Package not found on any mirror:"
						colorize 31 " $pkg"; newline
					fi
					if [ "$deps_unavail" ]; then
						gettext "Unavailable dependencies:"
						colorize 31 " $(echo $deps_unavail)"; newline
					fi
					addrc=1
					continue
				fi
				ndeps=0; for p in $plan_missing; do ndeps=$((ndeps + 1)); done
				if [ "$ndeps" -gt 1 ]; then
					gettext "Install plan:"; colorize 34 " $plan_missing"
					newline
				fi
			fi
			count=$(($count + 1))
			# log_install (tee) is the pipeline's tail, so its exit status —
			# not install_web's — is what the shell reports. Stash
			# install_web's real status in a file so a failed install (e.g.
			# an unavailable dependency caught only at install time, past
			# preflight) sets addrc and spk-add exits non-zero. busybox ash
			# has no PIPESTATUS; the rc file is the portable way.
			mkdir -p $logdir
			{ install_web $pkg; echo $? >"$logdir/.webrc.$$"; } | log_install $pkg
			[ "$(cat "$logdir/.webrc.$$" 2>/dev/null)" = 0 ] || addrc=1
			rm -f "$logdir/.webrc.$$" ;;
	esac
done

# Show all new counted packages in verbose mode
if [ "$verbose" ]; then
	gettext "Handled packages:"; colorize 34 " $count"
fi

exit $addrc
