#!/bin/sh
#
# Spk-rm - Remove 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 "Remove installed packages and their dependencies")

$(boldify $(gettext "Options:"))
  --auto         $(gettext "Remove without asking for confirmation")
  --force        $(gettext "Allow removing an essential package (dangerous)")
  --autoremove   $(gettext "Remove auto-installed packages no longer needed")
  --root=        $(gettext "Set the root file system path")
  --verbose   $(gettext "Be more verbose when removing 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 removal milestones as flat TSV (for the TUI)")

$(boldify $(gettext "Examples:"))
  $name package1 package2 packagesN
  $name package1 --auto --verbose

EOT
	exit 0
}

# Core packages whose removal bricks the system (no shell, no libc, no base
# files). spk-rm refuses to touch these — directly or dragged in as a reverse
# dep — unless --force is given. Keep this list short and truly critical.
ESSENTIAL="busybox glibc-base slitaz-base-files"
is_essential() {
	case " $ESSENTIAL " in *" $1 "*) return 0 ;; esac
	return 1
}

# Full transitive reverse-dependency closure of $1 — every installed
# package that (directly or indirectly) depends on it and would break if
# it went away. De-duplicated, $1 excluded. Builds the reverse-dep index
# once (O(n), libspk) then walks it, instead of rescanning all receipts
# at every step.
rdeps_all() {
	# mktemp is the safe path; the fallback rm -f's first so a pre-planted
	# symlink at the predictable name can't redirect the later write.
	local idx=$(mktemp 2>/dev/null)
	[ -n "$idx" ] || { idx="/tmp/spk-rdeps.$$"; rm -f "$idx"; }
	build_rdeps_index "$idx"
	rdeps_closure "$idx" "$1"
	rm -f "$idx"
}

# Sum the on-disk size (bytes) of the regular files a package owns, read
# from its files.list. Symlinks/dirs skipped. Missing list -> 0. This is
# the real space freed by removing the package.
pkg_disk_bytes() {
	local fl="$installed/$1/files.list" f s tot=0
	[ -f "$fl" ] || { echo 0; return; }
	while IFS= read -r f; do
		f="$root$f"
		[ -f "$f" ] && [ ! -L "$f" ] || continue
		s=$(stat -c %s "$f" 2>/dev/null)
		tot=$((tot + ${s:-0}))
	done < "$fl"
	echo $tot
}

# Format a byte count as a 1024-based human-readable size.
human_size() {
	echo "$1" | awk '{ t=$1;
		if(t>=1073741824) printf "%.1fG\n", t/1073741824;
		else if(t>=1048576) printf "%.1fM\n", t/1048576;
		else if(t>=1024) printf "%.1fK\n", t/1024;
		else printf "%dB\n", t; }'
}

# Orphans = auto-installed packages (.auto marker, set by spk-add --asdep)
# that nothing still-needed depends on. A package is removable when every
# package depending on it is itself in the removal set; iterate to a
# fixpoint over the reverse-dep index. Explicit packages are never reaped.
# Echoes the orphan list in dependents-first removal order.
autoremove_set() {
	local idx=$(mktemp 2>/dev/null)
	[ -n "$idx" ] || { idx="/tmp/spk-auto.$$"; rm -f "$idx"; }
	build_rdeps_index "$idx"
	local set="" changed=1 p d keep
	while [ "$changed" ]; do
		changed=
		for p in $(ls $installed 2>/dev/null); do
			[ -f "$installed/$p/.auto" ] || continue
			case " $set " in *" $p "*) continue ;; esac
			# Keep $p if any of its dependents is NOT being removed.
			keep=
			for d in $(awk -v x="$p" '$1==x{print $2}' "$idx"); do
				case " $set " in *" $d "*) ;; *) keep=yes; break ;; esac
			done
			[ "$keep" ] && continue
			set="$set $p"; changed=1
		done
	done
	rm -f "$idx"
	echo $set
}

# Remove exactly ONE installed package: hooks, files, receipt, DB line.
# No reverse-dep handling, no confirm, no cascade — the caller decides
# the full set and the order. Uses $pkg.
remove() {
	unset_receipt
	source_receipt $installed/$pkg/receipt
	# Never trust PACKAGE from the receipt for the destructive paths below:
	# a truncated/empty receipt (disk-full crash) leaves PACKAGE unset, and
	# `rm -rf $installed/$PACKAGE` would then expand to `rm -rf $installed/`
	# — the entire package DB. A PACKAGE that disagrees with the directory
	# name ($pkg) would wipe a *different* package. Pin every path to $pkg,
	# the directory we were actually asked to remove.
	if [ -z "$PACKAGE" ] || [ "$PACKAGE" != "$pkg" ]; then
		colorize 33 "$(gettext 'Corrupt receipt, using directory name:') $pkg"
		PACKAGE="$pkg"
	fi
	boldify $(gettext "Removing") $pkg
	separator
	[ "$verbose" ] && echo "DB: $installed"

	# Handle pre_remove
	if grep -q ^pre_remove $installed/$PACKAGE/receipt; then
		pre_remove $root || { gettext "WARNING: pre_remove failed:"
			colorize 31 " $PACKAGE"; newline; }
	fi

	# Remove all files
	fileslist=$installed/$PACKAGE/files.list
	gettext "Installed files:"
	echo -n " $(wc -l $fileslist | cut -d " " -f 1)"
	[ "$verbose" ] && newline
	# Read each path literally: a `for f in $(cat files.list)` glob-expands
	# any entry containing */?/[ against the live FS, turning one poisoned
	# files.list line into a mass rm. while-read never globs.
	while IFS= read -r file
	do
		[ -n "$file" ] || continue
		# Shared-file guard: never delete a file still owned by ANOTHER
		# installed package — doing so silently breaks the package that
		# stays (this is how a careless rm wiped glibc-base's loader and
		# the host /dev nodes shared via devtmpfs). Dependents removed
		# earlier in the same run already had their receipt/files.list
		# deleted, so they don't count here. Mirrors spk-add's modifier
		# check. $PACKAGE's own files.list is excluded by name.
		owner=$(grep -ls "^$(printf '%s' "$file" | grepesc)\$" \
			$installed/*/files.list 2>/dev/null | grep -v "/$PACKAGE/files.list")
		if [ -n "$owner" ]; then
			if [ "$verbose" ]; then
				gettext "Kept (shared):"; echo " ${root}${file}"
			fi
			continue
		fi
		if [ "$verbose" ]; then
			gettext "Removing:"; echo -n " ${root}${file}"
		fi
		remove_file "${root}${file}"
		[ "$verbose" ] && status
	done < $fileslist
	[ ! "$verbose" ] && status

	# Handle post_remove
	if grep -q ^post_remove $installed/$PACKAGE/receipt; then
		post_remove $root || { gettext "WARNING: post_remove failed:"
			colorize 31 " $PACKAGE"; newline; }
	fi

	# Remove package receipt.
	gettext "Removing package receipt..."
	rm -rf $installed/$PACKAGE
	# Drop the installed.$SUM line. The old sed matched
	# "$PACKAGE-$VERSION.tazpkg" literally, missing the real arch-suffixed
	# filename (bc-1.08.2-x86_64.tazpkg) and so leaving a stale md5 entry
	# on every removal — the very orphans spk-doctor now reports. Match
	# field 2 (the filename) accepting any arch suffix, same rule as
	# spk-up's check_pkgup. 'print' keeps $0 verbatim so the two-space
	# md5sum format is preserved.
	base="$PACKAGE-${VERSION}${EXTRAVERSION}"
	awk -v b="$base" '{
		n=$2; sub(/\.tazpkg$/,"",n)
		if (n==b) next                  # no-arch name (i486)
		m=n; sub(/-[^-]*$/,"",m)         # strip the -arch suffix
		if (m==b) next
		print
	}' $installed.$SUM > $installed.$SUM.$$ && mv $installed.$SUM.$$ $installed.$SUM
	# Drop the installed.info index line too (written by spk-add). Exact
	# field-1 match via awk so package names with regex chars (gtk+…) and
	# name prefixes (ncurses vs ncurses-dev) can't hit the wrong line.
	if [ -f $installed.info ]; then
		awk -F'\t' -v p="$PACKAGE" '$1!=p' $installed.info > $installed.info.$$ \
			&& mv $installed.info.$$ $installed.info
	fi
	status
	separator
}

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

[ "$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'
}

# Emit one trame envelope per rm call.
# Args: pkg action(removed|not-installed|would-remove) rdeps count bytes
emit_rm_trame() {
	local pkg="$1" action="$2" rdeps="$3" count="${4:-0}" bytes="${5:-0}"
	local changed=true
	[ "$action" = not-installed ] && changed=false
	local dry=true
	[ "$dry_run" ] || dry=false
	local arr first p
	arr="["; first=yes
	for p in $rdeps; do
		[ "$first" ] || arr="${arr},"; first=
		arr="${arr}\"$(json_escape "$p")\""
	done
	arr="${arr}]"
	printf '{"command":"rm","package":"%s","action":"%s","changed":%s,"dry_run":%s,"reverse_deps":%s,"packages":%s,"freed_size_bytes":%s,"freed_size":"%s"}\n' \
		"$(json_escape "$pkg")" "$action" "$changed" "$dry" "$arr" \
		"$count" "$bytes" "$(human_size $bytes)"
}

#
# Commands and exit
#

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

# --trame / --dry-run / --explain: short-circuit BEFORE the destructive
# remove() runs. Emits one envelope per pkg; rdeps_list is reported but
# spk-rm intentionally does NOT cascade to dependents (matches the
# existing human behaviour — user is warned, not auto-pruned).
if [ "$trame" ] || [ "$explain" ] || [ "$dry_run" ]; then
	for pkg in "$@"; do
		case "$pkg" in --*) continue ;; esac
		if ! is_package_installed "$pkg"; then
			[ "$trame" ]   && emit_rm_trame "$pkg" not-installed ""
			[ "$explain" ] && echo "# $pkg: not installed (no-op)"
			[ -z "$trame" ] && [ -z "$explain" ] && \
				echo "$pkg: not installed"
			continue
		fi
		# Full transitive closure + impact, same as the real removal.
		rdeps_list=$(rdeps_all "$pkg")
		count=0; bytes=0
		for p in $pkg $rdeps_list; do
			is_package_installed $p || continue
			count=$((count + 1))
			bytes=$((bytes + $(pkg_disk_bytes $p)))
		done
		# Flag essential packages in the plan (removal itself is guarded).
		ess=
		for p in $pkg $rdeps_list; do is_essential "$p" && ess="$ess $p"; done
		[ "$trame" ]   && emit_rm_trame "$pkg" would-remove "$rdeps_list" "$count" "$bytes"
		[ "$explain" ] && { for p in $rdeps_list $pkg; do
			is_essential "$p" && echo "spk-rm $p --auto --force" \
				|| echo "spk-rm $p --auto"; done; }
		[ -z "$trame" ] && [ -z "$explain" ] && {
			echo "Would remove: $pkg ($count package(s), $(human_size $bytes))"
			[ -n "$rdeps_list" ] && echo "Reverse-deps would break: $rdeps_list"
			[ -n "$ess" ] && echo "Essential (needs --force):$ess"
		}
	done
	exit 0
fi

#
# --raw : stream a flat TSV of milestones, one line per step, for the TUI
# (and scripts). The real removal runs UNCHANGED (remove()); only its human
# output is redirected to the log so the TSV stream stays clean. Format:
# state<TAB>step<TAB>pkg<TAB>msg  with  state = step | info | ok | error | done
# Symmetric to spk-add --raw. Like the human flow it drags out the package
# plus its transitive reverse-deps (dependents first) but never prompts.
#
if [ "$raw" ]; then
	check_root
	mkdir -p "$logdir" 2>/dev/null
	r_ok=0; r_skip=0; r_fail=0
	for arg in "$@"; do
		case "$arg" in --*) continue ;; esac

		if ! is_package_installed "$arg"; then
			printf 'ok\tskip\t%s\tnot installed\n' "$arg"
			r_skip=$((r_skip + 1)); continue
		fi

		printf 'step\tplan\t%s\tresolving reverse dependencies\n' "$arg"
		rdeps=$(rdeps_all "$arg")
		[ "$rdeps" ] && \
			printf 'info\tplan\t%s\talso removes: %s\n' "$arg" "$(echo $rdeps)"

		# Refuse an essential package (directly or via rdeps) unless --force.
		if [ -z "$force" ]; then
			ess=
			for p in $arg $rdeps; do is_essential "$p" && ess="$ess $p"; done
			if [ -n "$ess" ]; then
				printf 'error\tremove\t%s\tessential, use --force:%s\n' \
					"$arg" "$(echo $ess)"
				r_fail=$((r_fail + 1)); continue
			fi
		fi

		# Dependents first (leaves before what they need), then $arg itself.
		for p in $rdeps $arg; do
			is_package_installed "$p" || continue
			printf 'step\tremove\t%s\tremoving\n' "$p"
			pkg=$p
			remove >>"$logdir/raw-remove.log" 2>&1
			if ! is_package_installed "$p"; then
				printf 'ok\tremove\t%s\tremoved\n' "$p"
				log "Removed package: $p (raw)"
				r_ok=$((r_ok + 1))
			else
				printf 'error\tremove\t%s\tfailed (see log)\n' "$p"
				r_fail=$((r_fail + 1))
			fi
		done
	done
	done_msg="$r_ok removed"
	[ "$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
#

: ${count=0}
check_root

# --autoremove: reap auto-installed packages nothing depends on anymore.
# Stands alone (no package argument needed).
if [ "$autoremove" ]; then
	orphans=$(autoremove_set)
	if [ -z "$orphans" ]; then
		gettext "No orphan packages to remove"; newline
		exit 0
	fi
	count=0; bytes=0
	for p in $orphans; do
		count=$((count + 1)); bytes=$((bytes + $(pkg_disk_bytes $p)))
	done
	newline
	boldify $(gettext "Autoremove summary")
	separator
	gettext "Orphans     :"; colorize 31 " $(echo $orphans)"
	gettext "Packages    :"; colorize 34 " $count"
	gettext "Freed space :"; colorize 34 " $(human_size $bytes)"
	separator
	if [ -z "$auto" ]; then
		eval_gettext "Remove these \$count orphan(s)"
		if ! confirm; then
			gettext "Autoremove cancelled"; newline
			exit 0
		fi
	fi
	for p in $orphans; do
		is_package_installed $p || continue
		pkg=$p; remove
		log "Removed package: $p (autoremove)"
	done
	exit 0
fi

for arg in $@
do
	# Skip options
	case "$arg" in
		--*) continue
	esac
	# Be sure package is installed
	if ! is_package_installed $arg; then
		echo -n "$(boldify $arg) "
		gettext "package is not installed"; newline
		continue
	fi

	# Full removal set: the package plus its transitive reverse-deps.
	# Removing only $arg would break everything that depends on it, so
	# they are dragged out too — but the user is warned and must confirm.
	rdeps=$(rdeps_all $arg)

	# Refuse if the removal set contains an essential package (directly or
	# as a dragged-in reverse dep). --force overrides.
	if [ -z "$force" ]; then
		ess=
		for p in $arg $rdeps; do is_essential "$p" && ess="$ess $p"; done
		if [ -n "$ess" ]; then
			gettext "REFUSED: essential package(s), use --force:"
			colorize 31 " $(echo $ess)"; newline
			continue
		fi
	fi

	# Tally impacted packages and the disk space removal will free.
	pkgs_total=0; bytes=0
	for p in $arg $rdeps; do
		is_package_installed $p || continue
		pkgs_total=$((pkgs_total + 1))
		bytes=$((bytes + $(pkg_disk_bytes $p)))
	done

	# Impact summary — shown BEFORE anything is touched.
	newline
	boldify $(gettext "Removal summary")
	separator
	gettext "Package      :"; colorize 34 " $arg"
	if [ "$rdeps" ]; then
		gettext "Also removes :"; colorize 31 " $(echo $rdeps)"
	fi
	gettext "Packages     :"; colorize 34 " $pkgs_total"
	gettext "Freed space  :"; colorize 34 " $(human_size $bytes)"
	separator

	# Confirm by default — removal is destructive and may cascade.
	# --auto skips the prompt for scripts. Default answer is No.
	if [ -z "$auto" ]; then
		eval_gettext "Remove these \$pkgs_total package(s)"
		if ! confirm; then
			gettext "Removal cancelled"; newline
			continue
		fi
	fi

	# Remove dependents first (leaves before what they need), then $arg.
	for rdep in $rdeps; do
		is_package_installed $rdep || continue
		pkg=$rdep; remove
		log "Removed package: $rdep"
		count=$(($count + 1))
	done
	pkg=$arg; remove
	log "Removed package: $arg"
	count=$(($count + 1))
done

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

exit 0
