#!/usr/bin/env bash
. "${0%/*}"/../lib/common.bashlib || exit 1 #C#
init
depend cut diff file find grep head portageq qlist readlink scanelf sort stat tail uniq
esanitize
include atomf
usage <<-EOU
	Usage: ${0##*/} <atom|image> [atom2|image2]

	Compares an installation image (i.e. \${PORTAGE_TMPDIR}/[...]/image/),
	with either another image or the currently installed system copy.

	Arguments can either be a path to the image, or an atom representing it.
	If atom is imprecise (i.e. no version), or only one is given, then will
	guess the right versions based on timestamps (older with newest).

	Options:
	  -f, --no-filelist     Do not print filelist differences
	  -s, --no-soname       Do not print SONAME differences
	  -a, --no-abidiff      Do not print per-libraries abidiff
	  -z, --no-size         Do not print size differences when above threshold
	  -r, --no-report       Do not report statistics at the end
	  -x, --no-compare      Short for all of the above (intended for --single-*)
	(unless -r/-x, report will still have statistics for disabled options)

	  -T, --size-thres=%    Size difference percentage at which to display it
	                        (default: 10.0%, 0 to always display)

	  -B, --full-abidiff    Show complete abidiff output
	  -d, --quiet-nodebug   Do not warn if missing debug for abidiff (unless -W)
	      --timeout=[SECS]  Terminate abidiff after SECS seconds, can be very slow
	                        with some C++ libraries (default: 10, unlimited if 0)

	  -I, --image-only      When guessing what to compare, ignore system's copy.
	                        (simplifies comparing two images)

	  -p, --ignore-perms    Do not show file permissions differences in filelist
	                        (needed if system has special permission handling)
	  -P, --show-perms      Always show file permissions even if no differences
	  -K, --ver-keep        Do not replace filelist's versions+slots in names by *
	  -O, --ver-dironly     Limit filelist version replacements to directories
	(default is to try to prevent showing uninteresting version-only changes)

	      --no-skip-large   Do not abort even if operations would be slow when
	                        over 10000 installed files (e.g. gentoo-sources)

	  -M, --allow-missing   Do nothing and exit normally if lacking a 2nd image
	                        (intended for automated scripts)

	  -W, --confirm         Show all statistics even if no changes as confirmation

	  -F, --single-filelist Show full filelist for latest
	  -S, --single-soname	Show full soname list for latest
	  -Z, --single-size     Show size for latest
	  -L, --single-all      Short for --single-{filelist,soname,size}
	  -U, --single-auto     Auto-disable specified --single-* if two images
	                        (--single-* options can function with a single image)

	  -c, --no-color        Disable use of colors

	      --confdir=PATH    Configuration dir to use instead of defaults
	                        (@confdir@ + ${XDG_CONFIG_HOME:-~/.config}/@package@)
	      --dumpconfig      Display config and exit (> ${0##*/}.conf)

	      --root=PATH       Set ROOT (command-line-only, default: '${ROOT}')
	      --eprefix=PATH    Set EPREFIX (likewise, default: '${EPREFIX}')

	  -h, --help            Display usage information and exit
	      --version         Display version information and exit

	*Abidiff Notes*
	Requires debug symbols for full report (FEATURES=splitdebug and -g).
	Report showing '[BREAKING]' doesn't necessarily mean it's breaking
	revdeps without rebuilds, but it warrants testing them while built
	against old version. Order matters, downgrading often breaks ABI.

	*Known Limitations*
	May report incorrect changes if pkg_postinst or FEATURES performed live changes
	on files or permissions that are missing from the image (e.g. fcaps.eclass).

	May wrongly report file differences with a wildcard when PV mismatches the real
	version (e.g. 9999 live ebuilds) while comparing with one that does match.

	*Portage Integration*
	Can be integrated by using ${EROOT}/etc/portage/bashrc, either by using the
	example ${ROOT}@datadir@/bashrc or by manually adding:

	    source @datadir@/${0##*/}.bashrc

	    post_pkg_preinst() {
	        qa-cmp_post_pkg_preinst
	    }

	bashrc environment options (export/make.conf/package.env):
	  QA_CMP=y | =n         Enable or disable, can also use IWDT_ALL=y | =n
	  QA_CMP_CMD=${0##*/}     This script, needs to be changed if not in PATH
	  QA_CMP_ARGS=          Extra arguments to pass, see options above
	  QA_CMP_LOG=eqawarn    Portage output command, can also use IWDT_LOG=ewarn
	Note: eqawarn post-emerge log needs "qa" in make.conf's PORTAGE_ELOG_CLASSES
EOU
optauto args "${@}" <<-EOO
	f|!filelist=bool:true
	s|!soname=bool:true
	a|!abidiff=bool:true
	z|!size=bool:true
	r|!report=bool:true
	x|!compare=bool:true
	T|size-thres=float:1000
	B|full-abidiff=bool:false
	d|quiet-nodebug=bool:false
	timeout=int:10
	I|image-only=bool:false
	p|ignore-perms=bool:false
	P|show-perms=bool:false
	K|ver-keep=bool:false
	O|ver-dironly=bool:false
	!skip-large=bool:true
	M|allow-missing=bool:false
	W|confirm=bool:false
	F|single-filelist=bool:false
	S|single-soname=bool:false
	Z|single-size=bool:false
	L|single-all=bool:false
	U|single-auto=bool:false
	c|!color=bool:true
EOO
(( ${#args[@]} >= 1 )) || die "no atom/image specified, see \`${0##*/} --help\`"
(( ${#args[@]} <= 2 )) || die "too many atom/image given, see \`${0##*/} --help\`"

if ${O[single-all]}; then
	O[single-filelist]=true
	O[single-soname]=true
	O[single-size]=true
fi

${O[single-filelist]} || ${O[single-soname]} || ${O[single-size]} \
	&& CMP_SINGLE=true || CMP_SINGLE=false

! ${O[filelist]} && ! ${O[soname]} && ! ${O[abidiff]} \
	&& ! ${O[size]} && ! ${O[report]} \
	&& O[compare]=false

# cmp-get_compare2readable <compare>
#	Echoes a cmp-get_tocompare converted value to be more readable
cmp-get_compare2readable() {
	if [[ ${1} =~ .*/([^/]*/[^/]*/image) ]]; then
		# this may not always be category/version but is a fair to be shorter
		echo "${BASH_REMATCH[1]}"
	else
		echo "${1}"
	fi
}

# cmp-get_filelist <array> <compare>
#	Set <array> to sorted filelist from a cmp-get_tocompare value.
#	First three lines are specially set to:
#		1. ${PVR}:${SLOT} 2. prefix/ 3. size in bytes
#	usr/{lib,src}/debug/ excluded for simplification.
#	" (-rw-r--r-- user:group)" will be set after paths, or
#	" (?)" if unknown or O[ignore-perms] is set.
cmp-get_filelist() {
	local flimit=()
	${O[skip-large]} && flimit=(-n 10001)
	{
		local slot
		local -i size
		if [[ ${2::1} == = ]]; then
			local vdb
			vdb=$(cmp-get_vdb)

			size=$(<"${vdb}/${2#=}"/SIZE) \
				|| die "failed to read '${vdb}/${2#=}/SIZE'"
			slot=$(<"${vdb}/${2#=}"/SLOT) \
				|| die "failed to read '${vdb}/${2#=}/SLOT'"

			atomf "%e:${slot%/*}\n" "${2}" || die

			echo "${ROOT}"/

			echo "${size}"

			Q_VDB=${vdb#"${ROOT}"} \
				qlist -Cqe "${2}" | cut -d/ -f2- | grep -Ev '^usr/(lib|src)/debug/' | sort | {
					# Unlike find(1) below, qlist can't give file permissions.
					local file
					while read -r file; do
						echo -n "${file} "

						# System files may not always be accessible, and VDB
						# does not store this. Ignore errors with placeholder.
						# Also use the placeholder with ignore-perms.
						! ${O[ignore-perms]} \
							&& stat -c'(%A %U:%G)' "${ROOT}/${file}" 2>/dev/null \
							|| echo '(?)'
					done
				}
			# 141 == likely SIGPIPE due to -n on mapfile
			[[ ${PIPESTATUS[*]} == '0 0 '[01]' '@(0|141)' 0' ]] \
				|| die "qlist failed for '${2}'"
		else
			size=$(<"${2%/image}"/build-info/SIZE) \
					|| die "failed to read '${2%/image}/build-info/SIZE', failed build image?"
			slot=$(<"${2%/image}"/build-info/SLOT) \
					|| die "failed to read '${2%/image}/build-info/SLOT', failed build image?"

			cmp-get_image2atomf "%e:${slot%/*}\n" "${2}"

			echo "${2}/"

			echo "${size}"

			find "${2}" -regex "${2}/usr/\(lib\|src\)/debug" -prune -o \
				-not -type d -printf '%P (%M %u:%g)\n' | sort
			[[ ${PIPESTATUS[*]} == '0 '@(0|141) ]] || die "find failed for '${2}'"
		fi
	} | mapfile -t "${flimit[@]}" "${1}"

	if ${O[skip-large]}; then
		local -n ref=${1}
		(( ${#ref[@]} > 10000 )) &&
			die "aborting due to too many files, force with --no-skip-large"
	fi
}

# cmp-get_image2atomf <format> <normalized-image>
#	Echoes cmp-get_atomf for an image returned by cmp-get_normalized_image()
cmp-get_image2atomf() {
	local catpf

	catpf=$(<"${2%/image}"/build-info/CATEGORY) \
		|| die "failed to read '${2%/image}/build-info/CATEGORY', failed build image?"
	catpf+=/$(<"${2%/image}"/build-info/PF) \
		|| die "failed to read '${2%/image}/build-info/PF', failed build image?"

	atomf "${1}" "${catpf}" || die
}

# cmp-get_images <array> <atom>
#	Set <array> to 1-3 images full path and/or installed =CATEGORY/PF from
#	newest to older. First exact, cat/pn:slot (if specified), then cat/pn.
cmp-get_images() {
	{
		local atom dir match slot
		local -i ts

		# convert atom to something we can use
		if [[ ${2} == */* ]]; then
			match=${2}
		else
			# ${CATEGORY} is missing, ask portage for a match
			match=$(cmp-get_visible "${2}")
			match+=$(atomf "%s" "${2}") || die # keep explicit slot if given
		fi

		atomsp atom "${match}" || die
		atom[6]=${atom[6]:+:${atom[6]}}

		if ! ${O[image-only]}; then
			for match in "${2}" ${atom[2]}/${atom[3]}${atom[6]} ${atom[6]:+${atom[2]}/${atom[3]}}; do
				match=$(cmp-get_installed "${match}")
				if [[ ${match} ]]; then
					ts=$(<"$(cmp-get_vdb)/${match}"/BUILD_TIME) \
						|| die "failed to read '$(cmp-get_vdb)/${match}/BUILD_TIME'"
					echo "${atom[6]:-:0} ${ts} =${match}"
					break
				fi
			done
		fi

		set +f
		for dir in "$(cmp-get_tmp)/${atom[2]}"/*; do
			# need to atomf the directory too, ${PN} can contain -
			# (skip if fails, directory likely not created by the PM)
			match=$(atomf '%n' "${atom[2]}/${dir##*/}") || continue

			[[ ${atom[3]} == "${match}" ]] || continue # mismatch

			if [[ ${atom[6]} ]]; then
				# try to skip wrong slots
				[[ -e "${dir}"/build-info/SLOT ]] || continue # likely a failed image
				slot=$(<"${dir}"/build-info/SLOT) \
					|| die "failed to read '${dir}/build-info/SLOT'"
				[[ ${atom[6]} == :${slot%/*} ]] || continue
			fi

			[[ -e "${dir}"/build-info/BUILD_TIME ]] || continue # likely a failed image
			ts=$(<"${dir}"/build-info/BUILD_TIME) \
				|| die "failed to read '${dir}/build-info/BUILD_TIME'"

			dir+=/image
			[[ -d "${dir}" ]] || die "found unusable image at '${dir}'"

			echo "${atom[6]:-:0} ${ts} ${dir}"
		done
		set -f
	} | sort -nr | head -n 3 | cut -d' ' -f3- | map "${1}"
	# indirect ${?} not to mask ERR trap !SC2181
	(( ${?} )) && die "failed to get images for '${2}'"
}

# cmp-get_installed <atom>
#	Echoes CATEGORY/PF best match for installed atom, or empty string if none
declare -A CMP_INSTALLED=() CMP_INSTALLED_SET=()
cmp-get_installed() {
	if [[ ! ${CMP_INSTALLED_SET[${1}]+x} ]]; then
		# note: unlike best_visible, does not return false if no match
		CMP_INSTALLED[${1}]=$(portageq match "${EROOT:-/}" "${1}" | tail -n 1) \
			|| die "portageq match failed for '${1}'"
		CMP_INSTALLED_SET[${1}]=y
	fi

	echo "${CMP_INSTALLED[${1}]}"
}

# cmp-get_makereport <format> <stat> <color> <current-report>
#	Echos report entry if non-zero (or if --confirm) using <format>/<color>
#	If stat is >=, format can have %d to display <stat>
#	If stat is -1, close report
cmp-get_makereport() {
	if [[ ${2} == -1 ]]; then
		if [[ ${4} ]] || ${O[confirm]}; then
			echo "${C[${3}]}${1}(${C[n]}${4}${C[${3}]})${C[n]}"
		fi
	elif (( ${2} )) || ${O[confirm]}; then
		[[ ${4} ]] && echo -n "${4}${C[a]},${C[n]}"
		printf -- "${C[${3}]}${1}${C[n]}" "${2}"
	else
		echo -n "${4}"
	fi
}

# cmp-get_normalized_image <atom|image>
#	Echoes image full path for an exact image or atom
#	Returns 1 and an empty string if could not find an exact match
#	Returns 2 if argument may have been intended as an atom
cmp-get_normalized_image() {
	local image=${1} atom=0

	if absdir image; then
		:
	elif [[ -d $(cmp-get_tmp)/${image#=} ]]; then
		image=$(cmp-get_tmp)/${image#=} # exact tmp dir match
		atom=2
	elif [[ ${image::1} == = && ${image} != */* ]]; then
		# likely supplied a mostly-exact =package-0.0.0 without category, ask portageq
		image=$(cmp-get_tmp)/$(cmp-get_visible "${1}")
		[[ -d ${image} ]] || return 1
		atom=2
	else
		return 1
	fi

	# hopefully image itself does not have this dir
	[[ -d ${image}/image ]] && image+=/image

	echo "${image}"
	return ${atom}
}

# cmp-get_scanelf <array> <args>
#	Wrapper for scanelf to echo `sort | uniq` list and check for errors
cmp-get_scanelf() {
	scanelf -q "${@:2}" | sort | uniq | map "${1}" \
		|| die "scanelf failed with args: '-q ${*}'"
}

# cmp-get_shared <array> <possible libraries...>
#	Set <array> to shared libraries from the given list.
#	Symbolic links will also be skipped as the non-link library is
#	expected to be in the list.
cmp-get_shared() {
	local -n outref=${1} #!SC2178
	shift

	local -a solibs=()
	while (( ${#} )); do
		# trim list for less checks, not interested in obscure not-.so
		if [[ ${1} == *.so* && ! -L ${1} ]]; then
			[[ -r ${1} ]] || die "'${1}' is not readable"
			solibs+=("${1}")
		fi
		shift
	done

	if (( ! ${#solibs[@]} )); then
		outref=() #!SC2034
		return 0
	fi

	file -i0 -- "${solibs[@]}" | grep -aF 'x-sharedlib' | cut -d '' -f1 | map outref
	[[ ${PIPESTATUS[*]} == '0 '[01]' 0 0' ]] \
		|| die "file check for shared libraries failed"
}

# cmp-get_tmp
#	Echos portage's tmpdir or die if basic sanity checks (intended to
#	give less confusing errors) failed
#
#	Not set on init given this may not always be needed, e.g. if image
#	directories are in ${HOME} then ${PORTAGE_TMPDIR} doesn't matter
CMP_TMP=
cmp-get_tmp() {
	# cache given portageq invocation is not the fastest thing
	if [[ ${CMP_TMP} ]]; then
		echo "${CMP_TMP}"
		return
	fi

	if [[ ${PORTAGE_TMPDIR:-} ]]; then
		CMP_TMP=${PORTAGE_TMPDIR}
	else
		CMP_TMP=$(portageq envvar PORTAGE_TMPDIR || die "portageq failed to return PORTAGE_TMPDIR")
	fi
	[[ ${CMP_TMP} ]] || die "could not determine PORTAGE_TMPDIR"

	CMP_TMP+=/portage
	absdir CMP_TMP || die "'${CMP_TMP}' is not a directory"

	# portage often uses 700 for tmp/cat/pkg which can lead to confusing errors,
	# e.g. -d tmp/cat/pkg/image is 'not a directory' despite being one
	set +f
	local dirs=("${CMP_TMP}"/*/*)
	if [[ ${#dirs[@]} != 0 && -d ${dirs[0]} && ! -x ${dirs[0]} ]]; then
		die "permission denied for image dirs under '${CMP_TMP}'"
	fi
	set -f

	echo "${CMP_TMP}"
}

# cmp-get_tocompare <array> <atom|image> [atom2|image2]
#	Set <array> to what to compare based on rough arguments, first being
#	oldest, and 2nd the newest to compare with.
#	Will either be a full path to the image, or =CATEGORY/PF if installed
cmp-get_tocompare() {
	local -n outref=${1} #!SC2178
	local -a i1 i2=()

	# Given allow user to specify all sort of things (atom, image path,
	# specific versions or not), need to do a bit of messy guesswork to
	# find what to compare. Optimally would be two arguments where
	# cmp-get_normalized_image returned the exact match.
	i1[0]=$(cmp-get_normalized_image "${2}")
	case ${?} in
		1)	# no match, fallback to cat/pn search
			cmp-get_images i1 "${2}"
		;;
		2)	# matched but if ${3} is same then one of them is meant
			# to match system (can't tell which), discard and search
			if [[ ${2} == "${3:-}" ]]; then
				shift
				cmp-get_images i1 "${2}"
			fi
		;;
	esac

	if (( ${#} == 3 )); then
		i2[0]=$(cmp-get_normalized_image "${3}")
		(( ${?} == 1 )) && cmp-get_images i2 "${3}"
	elif (( ${#i1[@]} >= 2 )); then
		# use first image as newest (in i2), and 2nd as older (in i1)
		i2[0]=${i1[0]}
		i1[0]=${i1[1]}
	elif (( ${#i1[@]} )); then
		# last hope to try to find something to compare with by using
		# the image's category/pn from build-info
		if [[ ${i1[0]::1} != = ]]; then
			local catpn
			catpn=$(cmp-get_image2atomf '%p' "${i1[0]}")
			cmp-get_images i2 "${catpn}"
		fi
	fi

	# shift until i1[0] != i2[0]
	while (( ${#i1[@]} && ${#i2[@]} )) && [[ ${i1[0]} == "${i2[0]}" ]]; do
		if (( ${#i1[@]} >= ${#i2[@]} )); then
			i1=("${i1[@]:1}")
		else
			i2=("${i2[@]:1}")
		fi
	done

	if ! (( ${#i1[@]} && ${#i2[@]} )); then
		if ${CMP_SINGLE}; then
			if (( ${#i2[@]} )); then
				outref=("${i2[0]}")
				return 0
			elif (( ${#i1[@]} )); then
				outref=("${i1[0]}")
				return 0
			fi
		fi

		if ${O[allow-missing]}; then
			end
		elif (( ${#i1[@]} && ${#i2[@]} )); then
			die "could not find images for '${*:2}'"
		else
			die "found nothing to compare with, did you mean to use --single-*?"
		fi
	fi

	outref=("${i1[0]}" "${i2[0]}") #!SC2034
}

# cmp-get_vdb
#	Echos portage's vdb path (similar to cmp-get_tmp)
CMP_VDB=
cmp-get_vdb() {
	if [[ ! ${CMP_VDB} ]]; then
		CMP_VDB=$(portageq vdb_path) || die "portageq vdb_path failed"
		[[ -d ${CMP_VDB} ]] || die "portageq returned '${CMP_VDB}' as VDB path which does not appear usable"
	fi
	echo "${CMP_VDB}"
}

# cmp-get_visible <atom>
#	Echoes CATEGORY/PF best visible for atom, or die if none
declare -A CMP_VISIBLE=() CMP_VISIBLE_SET=()
cmp-get_visible() {
	if [[ ! ${CMP_VISIBLE_SET[${1}]+x} ]]; then
		CMP_VISIBLE[${1}]=$(portageq best_visible "${EROOT:-/}" "${1}") \
			|| die "portageq best_visible failed for '${1}', atom not in tree?"
		# empty string shouldn't happen as it returns false, but check anyway
		[[ ${CMP_VISIBLE[${1}]} ]] || die "portageq best_visible returned an empty string for '${1}'"
		CMP_VISIBLE_SET[${1}]=y
	fi

	echo "${CMP_VISIBLE[${1}]}"
}

# cmp-output_abi_diff
#	Add to OUTPUT abi differences between libraries of LIST_OLD and LIST_NEW
#	that share the same SONAME, requires to have run cmp-output_soname_diff
cmp-output_abi_diff() {
	if ! (( ${#SONAME_OLD[@]} || ${#SONAME_NEW[@]} )); then
		cmp-output_abi_diff_issue "N/A" 0 # no libraries
		return
	fi

	# create list of files that exist in both (aka unchanged soname)
	# SONAME_* entries are "soname.so.0 /path/to/file"
	local -i i j
	local -a old=() new=()
	for ((i=0; i < ${#SONAME_OLD[@]}; i++)); do
		for ((j=0; j < ${#SONAME_NEW[@]}; j++)); do
			if [[ ${SONAME_OLD[i]%% *} == "${SONAME_NEW[j]%% *}" ]]; then
				old+=("${SONAME_OLD[i]}")
				new+=("${SONAME_NEW[j]}")
			fi
		done
	done

	if ! (( ${#old[@]} || ${#new[@]} )); then
		cmp-output_abi_diff_issue "---" 0 # all sonames changed
		return
	fi

	if ! type abidiff &>/dev/null; then
		# if no abidiff, assume unwanted -- make this a semi-hidden warning
		cmp-output_abi_diff_issue "no-abidiff" 0
		return
	fi

	local timeout=()
	if (( O[timeout] > 0 )); then
		# we technically require GNU coreutils, but could be missing
		if ! type timeout &>/dev/null; then
			cmp-output_abi_diff_issue "no-timeout" 0
			return
		fi

		timeout=(timeout "${O[timeout]}")
	fi

	# abidiff has a tendency to output nothing and return 0 if no debug
	# info and --fail-no-debug-info doesn't change this last checked, but
	# --stats should always output something /if/ it's going to work
	# (this include a few changes it can find without debug info)
	# TODO?: do own check for debug to inform report may be incomplete,
	#        ideally want to get rid of this abidiff duplication
	local output report
	local debugold=${LIST_OLD[1]}usr/lib/debug
	local debugnew=${LIST_NEW[1]}usr/lib/debug
	local -i errno
	for ((i=0; i < ${#old[@]}; i++)); do
		output=$(
			"${timeout[@]}" abidiff --stats \
				--d1 "${debugold}" \
				--d2 "${debugnew}" \
				"${old[i]#* }" "${new[i]#* }" 2>&1)
		errno=${?}

		if (( ${#timeout[@]} && errno >= 124 )); then
			cmp-output_abi_diff_issue "TIMEOUT" 1
			return
		fi

		# ABIDIFF_ERROR = 1
		# ABIDIFF_USAGE_ERROR = 2
		# ABIDIFF_ABI_CHANGE = 4
		# ABIDIFF_ABI_INCOMPATIBLE_CHANGE = 8
		if (( errno & 3 )); then
			[[ ${output} == *"could not find the ELF symbols in the file"* ]] \
				&& continue # likely a stub library, ignore
			# give message for troubleshooting, but don't || die
			err "${output}"
			cmp-output_abi_diff_issue "FAIL" 1
			return
		fi

		if [[ ! ${output} ]]; then
			if ${O[quiet-nodebug]}; then
				cmp-output_abi_diff_issue "nodebug" 0
			else
				cmp-output_abi_diff_issue "nodebug" 1
			fi
			return
		fi
	done

	local line
	local -i add=0 chg=0 del=0 brk=0
	local -i fr fc fa vr vc va
	for ((i=0; i < ${#old[@]}; i++)); do
		output=$(
			LC_MESSAGES=C "${timeout[@]}" abidiff \
				--d1 "${debugold}" \
				--d2 "${debugnew}" \
				"${old[i]#* }" "${new[i]#* }" 2>&1)
		errno=${?}

		if (( ${#timeout[@]} && errno >= 124 )); then
			cmp-output_abi_diff_issue "TIMEOUT" 1
			return
		fi

		if (( errno & 3 )); then
			[[ ${output} == *"could not find the ELF symbols in the file"* ]] \
				&& continue # likely a stub library, skip
			msg "${output} (${FUNCNAME[0]})"
			cmp-output_abi_diff_issue "FAIL" 1
			return
		fi

		(( errno & 12 )) || continue # no changes

		if [[ ${output} =~ .*:\ ([0-9]+)\ Removed[^:]*,\ ([0-9]+)\ Changed[^:]*,\ ([0-9]+)\ Added.*:\ ([0-9]+)\ Removed[^:]*,\ ([0-9]+)\ Changed[^:]*,\ ([0-9]+)\ Added ]]; then
			fr=${BASH_REMATCH[1]}
			fc=${BASH_REMATCH[2]}
			fa=${BASH_REMATCH[3]}
			vr=${BASH_REMATCH[4]}
			vc=${BASH_REMATCH[5]}
			va=${BASH_REMATCH[6]}
		else
			# shouldn't(?) happen unless a libabigail bump changed things around
			cmp-output_abi_diff_issue "parse-error" 1
			return
		fi

		if [[ ${output} =~ .*:\ ([0-9]+)\ Removed[^:]*,\ ([0-9]+)\ Added[^:]*'not referenced by debug info'.*:\ ([0-9]+)\ Removed[^:]*,\ ([0-9]+)\ Added ]]; then
			fr+=${BASH_REMATCH[1]}
			fa+=${BASH_REMATCH[2]}
			vr+=${BASH_REMATCH[3]}
			va+=${BASH_REMATCH[4]}
		fi

		(( add+=fa+va ))
		(( del+=fr+vr ))
		(( chg+=fc+vc ))
		(( errno & 8 )) && brk+=1

		if ${O[abidiff]}; then
			OUTPUT+="   ${C[a]}ABI: ${C[m]}${old[i]%% *}${C[n]}"
			report=$(cmp-get_makereport "+%d" "${fa}" g '')
			report=$(cmp-get_makereport "~%d" "${fc}" y "${report}")
			report=$(cmp-get_makereport "-%d" "${fr}" r "${report}")
			report=$(cmp-get_makereport "func" -1 lb "${report}")
			[[ ${report} ]] && OUTPUT+=" ${report}"
			report=$(cmp-get_makereport "+%d" "${va}" g '')
			report=$(cmp-get_makereport "~%d" "${vc}" y "${report}")
			report=$(cmp-get_makereport "-%d" "${vr}" r "${report}")
			report=$(cmp-get_makereport "vars" -1 lb "${report}")
			[[ ${report} ]] && OUTPUT+=" ${report}"
			(( errno & 8 )) && OUTPUT+=" ${C[r]}[BREAKING]${C[n]}"
			OUTPUT+=$'\n'
		fi

		${O[full-abidiff]} && OUTPUT+="${output}"$'\n'
	done

	report=$(cmp-get_makereport "+%d" "${add}" g '')
	report=$(cmp-get_makereport "~%d" "${chg}" y "${report}")
	report=$(cmp-get_makereport "-%d" "${del}" r "${report}")
	(( brk )) && report=$(cmp-get_makereport ">B<" 1 r "${report}")
	report=$(cmp-get_makereport "ABI" -1 a "${report}")
	[[ ${report} ]] && REPORT+=" ${report}"
}

# cmp-output_abi_diff_issue <shortmsg> <showissue>
#	Add to REPORT for a cmp-output_abi_diff issue using <shortmsg>
#	If showissue is 0, will only show with --confirm, or always if 1
cmp-output_abi_diff_issue() {
	local report
	report=$(cmp-get_makereport "${1}" "${2}" r '')
	report=$(cmp-get_makereport "ABI" -1 a "${report}")
	[[ ${report} ]] && REPORT+=" ${report}"
}

# cmp-output_filelist <compare1>
#	Add to OUTPUT filelist for 1 value from cmp-get_tocompare
#	Simplified version of cmp-output_filelist_diff
cmp-output_filelist() {
	${O[single-filelist]} || return 0

	local line file perm
	local -a pedit
	for line in "${LIST_NEW[@]:3}"; do
		# simplified perms handling from cmp-output_filelist_diff
		file=${line% (*}
		if ${O[ignore-perms]} || ! ${O[show-perms]}; then
			line=${file}
		else
			perm=${line#"${file}"}

			# use different color for suid bits and non-root user:group
			if [[ ${perm} =~ \ \((.{10})\ (.*):(.*)\)$ ]]; then
				pedit=("${BASH_REMATCH[@]:1}")
				pedit[0]=${pedit[0]//s/${C[y]}s${C[a]}}
				[[ ${pedit[1]} == root ]] || pedit[1]=${C[m]}${pedit[1]}${C[a]}
				[[ ${pedit[2]} == root ]] || pedit[2]=${C[m]}${pedit[2]}${C[a]}
				perm=" (${pedit[0]} ${pedit[1]}:${pedit[2]})"
			fi

			line=${file}${C[a]}${perm}
		fi

		OUTPUT+=" ${C[a]}FILES: ${C[g]}${line}${C[n]}"$'\n'
	done
}

# cmp-output_filelist_diff
#	Add to OUTPUT differences between LIST_OLD and LIST_NEW while optionally
#	omitting changes to version in filenames (replaced by *)
cmp-output_filelist_diff() {
	local -a old=() new=()

	if ${O[ver-keep]}; then
		old=("${LIST_OLD[@]:3}")
		new=("${LIST_NEW[@]:3}")
	else
		# To focus on showing interesting filelist changes rather than "dir
		# name changed version and all files under it show in the diff", strip
		# version and replace by placeholder. Unknown which version these use
		# so try ${PVR}->${PV}->${PV%%_*} for the most common.
		_cmp-output_filelist_diff-replace_versions LIST_OLD old
		_cmp-output_filelist_diff-replace_versions LIST_NEW new
	fi

	local report
	if ! (( ${#old[@]} || ${#new[@]} )); then
		# this is rather unlikely
		report=$(cmp-get_makereport "N/A" 0 r '')
		report=$(cmp-get_makereport "FILES" -1 a "${report}")
		[[ ${report} ]] && REPORT+=" ${report}"
		return
	fi

	local file line
	if ${O[ignore-perms]}; then
		old=("${old[@]% (*}")
		new=("${new[@]% (*}")
	else
		# hack: normalize/fake permissions for symlinks, some filesystems
		# seem to handle these differently and it generates noise
		old=("${old[@]/ \(l[r-][w-][x-][r-][w-][x-][r-][w-][x-] / \(lrwxrwxrwx }") #!SC2180
		new=("${new[@]/ \(l[r-][w-][x-][r-][w-][x-][r-][w-][x-] / \(lrwxrwxrwx }") #!SC2180

		# hack: use search index to know why a line is different after
		# diff and to avoid checking permission differences if unknown.
		# TODO?: could use refactoring and self-diffing for conditions
		# rather than call diff(1) with bad hacks, would also allow for
		# "same | line" to display a change rather than addition/removal
		local -A oldindex newindex
		for line in "${old[@]}"; do
			oldindex[${line% (*}]="(${line##* (}"
		done
		for line in "${new[@]}"; do
			newindex[${line% (*}]="(${line##* (}"
		done

		local -a iter
		iter=("${old[@]}")
		old=()
		for line in "${iter[@]}"; do
			file=${line% (*}
			if [[ ${newindex[${file}]+x} ]]; then
				if [[ ${oldindex[${file}]} != '(?)' && ${newindex[${file}]} == '(?)' ]]; then
					line="${file} (?)"
				elif [[ ${oldindex[${file}]:1:10} =~ s && ${newindex[${file}]:1:10} =~ s ]]; then
					# hack: FEATURES=sfperms (default) cause portage to do
					# chmod go-r /during/ merge which mismatches with the image
					# and we have no way to tell if this is ebuild-intended.
					# Not right but if 's' exists on both, pretend same g/o+r.
					oldindex[${file}]=${oldindex[${file}]::5}${newindex[${file}]:5:1}${oldindex[${file}]:6:2}${newindex[${file}]:8:1}${oldindex[${file}]:9}
					line="${file} ${oldindex[${file}]}"
				fi
			fi
			old+=("${line}")
		done

		iter=("${new[@]}")
		new=()
		for line in "${iter[@]}"; do
			file=${line% (*}
			if [[ ${newindex[${file}]} != '(?)' && ${oldindex[${file}]:-} == '(?)' ]]; then
				new+=("${file} (?)")
			else
				new+=("${line}")
			fi
		done
	fi

	# sort may be needed if version replacements were made
	local -a output
	diff -U0 \
		<(printarray old | sort || die) \
		<(printarray new | sort || die) \
		| grep -v '^@@\|^---\|^+++' | map output
		[[ ${PIPESTATUS[*]} == [01]\ [01]\ 0 ]] || die

	local color perm
	local -a pedit
	local -i add=0 del=0
	for line in "${output[@]}"; do
		case ${line::1} in
			+) color=${C[g]}; add+=1;;
			-) color=${C[r]}; del+=1;;
			*) color=${C[a]};; # unused unless increase -U*
		esac

		if ${O[filelist]}; then
			if ${O[ignore-perms]}; then
				line=${color}${line}${C[n]}
			else
				file=${line:1}
				file=${file% (*}
				perm=${line#?"${file}"}

				# use different color for suid bits and non-root user:group
				if [[ ${perm} =~ \ \((.{10})\ (.*):(.*)\)$ ]]; then
					pedit=("${BASH_REMATCH[@]:1}")
					pedit[0]=${pedit[0]//s/${C[y]}s${color}}
					[[ ${pedit[1]} == root ]] || pedit[1]=${C[m]}${pedit[1]}${color}
					[[ ${pedit[2]} == root ]] || pedit[2]=${C[m]}${pedit[2]}${color}
					perm=" (${pedit[0]} ${pedit[1]}:${pedit[2]})"
				fi

				# display permissions in different colors if file is in both
				if [[ ${oldindex[${file}]+x} && ${newindex[${file}]+x} ]]; then
					line=${color}${line::1}${C[a]}${file}${color}${perm}${C[n]}
				elif ${O[show-perms]}; then
					line=${color}${line::1}${file}${C[a]}${perm}${C[n]}
				else
					line=${color}${line::1}${file}${C[n]}
				fi
			fi

			OUTPUT+=" ${C[a]}FILES:${line}"$'\n'
		fi
	done

	report=$(cmp-get_makereport "+%d" ${add} g '')
	report=$(cmp-get_makereport "-%d" ${del} r "${report}")
	report=$(cmp-get_makereport "FILES" -1 a "${report}")
	[[ ${report} ]] && REPORT+=" ${report}"
}
_cmp-output_filelist_diff-replace_versions() {
	local -n list=${1} out=${2}
	local file line path ver
	for line in "${list[@]:3}"; do
		file=${line% (*}
		${O[ver-dironly]} && [[ ${file} =~ / ]] && path=${file%/*} || path=${file}
		for ver in "${list[0]}" "${list[0]%-r[0-9]*}" "${list[0]%%_*}"; do
			# shouldn't happen, but infinite guard in case of bad data
			[[ ! ${ver} || ${ver} == '*' ]] && continue

			# replace all seemingly exact version not near other numbers with '*'
			while [[ ${path} =~ (^|[^0-9.]|[^0-9]\.)("${ver%:*}")([^0-9.]|\.[^0-9]|$) ]]; do
				path=${path//"${BASH_REMATCH[0]}"/${BASH_REMATCH[1]}*${BASH_REMATCH[3]}}
			done

			# likewise for SLOT but further limit to non-alnum as it may not
			# necessarily start with a number unlike versions (it can also
			# start with _ but ignore it to allow _<slot> replacement)
			while [[ ${path} =~ (^|[^[:alnum:].]|[^0-9]\.)("${ver#*:}")([^[:alnum:].]|\.[^0-9]|$) ]]; do
				path=${path//"${BASH_REMATCH[0]}"/${BASH_REMATCH[1]}*${BASH_REMATCH[3]}}
			done
		done
		${O[ver-dironly]} && [[ ${file} =~ / ]] && path+=/${file##*/}
		out+=("${path} (${line##* (}")
	done
}

# cmp-output_size <compare1>
#	Add to OUTPUT size and filecount for 1 value from cmp-get_tocompare
#	Simplified version of cmp-output_size_diff
cmp-output_size() {
	${O[single-size]} || return 0

	OUTPUT+=$(printf "  ${C[a]}SIZE: ${C[y]}%.2fMiB${C[a]}, ${C[y]}%d${C[a]} files${C[n]}\n" \
		$((10**2 * LIST_NEW[2] / 1024**2))e-2 $((${#LIST_NEW[@]} - 3)))
}

# cmp-output_size_diff
#	Add to OUTPUT difference in size
cmp-output_size_diff() {
	local sper
	local -i sold=${LIST_OLD[2]}
	local -i snew=${LIST_NEW[2]}

	if (( sold && snew )); then
		(( sper = 10**4 * snew / sold - 10**4 ))
	elif (( snew )); then
		sper=10000 # pretend 0->1 is 100%
	elif (( sold )); then
		sper=-10000
	else
		sper=0
	fi

	if ${O[size]} && (( ${sper#-} >= O[size-thres] )); then
		OUTPUT+=$(
			printf "  ${C[a]}SIZE: ${C[y]}%.2fMiB${C[a]} -> ${C[y]}%.2fMiB${C[a]}, ${C[y]}%d${C[a]} -> ${C[y]}%d files${C[n]}\n" \
				$((10**2 * sold / 1024**2))e-2 \
				$((10**2 * snew / 1024**2))e-2 \
				$((${#LIST_OLD[@]} - 3)) \
				$((${#LIST_NEW[@]} - 3)))$'\n'
	fi

	if ${O[confirm]} || (( ${sper#-} >= O[size-thres] )); then
		printf -v sper "%.2f" ${sper}e-2
		if (( snew >= sold )); then
			report=$(cmp-get_makereport "+${sper}%%" 1 g '')
		else
			report=$(cmp-get_makereport "${sper}%%" 1 r '')
		fi
		report=$(cmp-get_makereport "SIZE" -1 a "${report}")
		[[ ${report} ]] && REPORT+=" ${report}"
	fi
}

# cmp-output_soname <compare>
#	Add to OUTPUT soname list for 1 value from cmp-get_tocompare
#	Simplified version of cmp-output_soname_diff
cmp-output_soname() {
	${O[single-soname]} || return

	local -a new=("${LIST_NEW[@]:3}") tmp

	new=("${new[@]% (*}") # discard extra info and keep paths

	cmp-get_shared new "${new[@]/#/${LIST_NEW[1]}}"
	if (( ${#new[@]} )); then
		cmp-get_scanelf tmp -M32 -F'%S(32)#F' -- "${new[@]}"
		cmp-get_scanelf +tmp -M64 -F'%S(64)#F' -- "${new[@]}"
		(( ${#tmp[@]} )) || cmp-get_scanelf tmp -F'%S#F' -- "${new[@]}"
		new=("${tmp[@]}")
	fi

	local line
	for line in "${new[@]}"; do
		OUTPUT+="${C[a]}SONAME: ${C[c]}${line}${C[n]}"$'\n'
	done
}

# cmp-output_soname_diff
#	Add to OUTPUT differences between LIST_OLD and LIST_NEW but only
#	for SONAME changes
cmp-output_soname_diff() {
	local -a new=("${LIST_NEW[@]:3}") old=("${LIST_OLD[@]:3}") tmp

	new=("${new[@]% (*}") # discard extra info and keep paths
	old=("${old[@]% (*}")

	cmp-get_shared new "${new[@]/#/${LIST_NEW[1]}}"
	cmp-get_shared old "${old[@]/#/${LIST_OLD[1]}}"

	# replace lists with scanelf output, do three runs to differentiate
	# 32bit/64bit without guessing what the libdir may be, and one more
	# if got nothing (to handle potential exotic arches)
	# TODO?: lacks further awareness of different versions with same soname
	if (( ${#old[@]} )); then
		cmp-get_scanelf tmp -M32 -F'%S(32)' -- "${old[@]}"
		cmp-get_scanelf +tmp -M64 -F'%S(64)' -- "${old[@]}"
		(( ${#tmp[@]} )) || cmp-get_scanelf tmp -F'%S' -- "${old[@]}"
		old=("${tmp[@]}")
	fi
	if (( ${#new[@]} )); then
		cmp-get_scanelf tmp -M32 -F'%S(32)' -- "${new[@]}"
		cmp-get_scanelf +tmp -M64 -F'%S(64)' -- "${new[@]}"
		(( ${#tmp[@]} )) || cmp-get_scanelf tmp -F'%S' -- "${new[@]}"
		new=("${tmp[@]}")
	fi

	# need uniq on field1, and 2+ can have spaces (`rev | uniq -f1` unsafe),
	# do manually (also save to SCANELF_* to be re-used by cmp-output_abi_diff)
	local elem
	local -A uniq=()
	for elem in "${old[@]}"; do
		if [[ ! ${uniq[${elem%% *}]:-} ]]; then
			uniq[${elem%% *}]=:
			SONAME_OLD+=("${elem}")
		fi
	done
	uniq=()
	for elem in "${new[@]}"; do
		if [[ ! ${uniq[${elem%% *}]:-} ]]; then
			uniq[${elem%% *}]=:
			SONAME_NEW+=("${elem}")
		fi
	done

	local report
	if [[ ${#SONAME_OLD[@]} == 0 && ${#SONAME_NEW[@]} == 0 ]]; then
		report=$(cmp-get_makereport "N/A" 0 r '')
		report=$(cmp-get_makereport "SONAME" -1 a "${report}")
		[[ ${report} ]] && REPORT+=" ${report}"
		return # nothing to compare
	fi

	old=("${SONAME_OLD[@]%% *}")
	new=("${SONAME_NEW[@]%% *}")

	local -a output
	diff -U0 \
		<(printarray old) \
		<(printarray new) \
		| grep -v '^@@\|^---\|^+++' | map output
	[[ ${PIPESTATUS[*]} == [01]\ [01]\ 0 ]] || die

	local color line
	local -i add=0 del=0
	for line in "${output[@]}"; do
		case ${line::1} in
			+) color=${C[c]}; add+=1;;
			-) color=${C[y]}; del+=1;;
			*) color=${C[a]};; # unused unless increase -U*
		esac
		${O[soname]} && OUTPUT+=" ${C[a]}SONAME:${color}${line}${C[n]}"$'\n'
	done

	report=$(cmp-get_makereport "+%d" ${add} c '')
	report=$(cmp-get_makereport "-%d" ${del} y "${report}")
	report=$(cmp-get_makereport "SONAME" -1 a "${report}")
	[[ ${report} ]] && REPORT+=" ${report}"
}

# cmp-print_header <compare1> <compare2>
#	Print what is being compared using values from cmp-get_tocompare
cmp-print_header() {
	local old new
	old=$(cmp-get_compare2readable "${1}")
	new=$(cmp-get_compare2readable "${2}")
	msg "CMP: ${old} with ${new}"
}

# cmp-print_header_single <compare>
#	Print single image being checked using values from cmp-get_tocompare
cmp-print_header_single() {
	msg "CMP: listing $(cmp-get_compare2readable "${1}") info"
}

# normalize arguments to something we can use
cmp-get_tocompare args "${args[@]}"
set -- "${args[@]}"; unset args

# init globals used by cmp-output_*
OUTPUT=
REPORT=
declare -a LIST_OLD LIST_NEW SONAME_OLD=() SONAME_NEW=()
if (( ${#} == 2 )); then
	cmp-get_filelist LIST_OLD "${1}"
	cmp-get_filelist LIST_NEW "${2}"
	${O[single-auto]} && CMP_SINGLE=false
else
	cmp-get_filelist LIST_NEW "${1}"
fi

if ${CMP_SINGLE}; then
	cmp-output_filelist
	cmp-output_soname
	cmp-output_size

	if [[ ${OUTPUT} ]]; then
		cmp-print_header_single "${2:-${1}}"
		msg "${OUTPUT%$'\n'}"
		OUTPUT=
	fi
fi

if (( ${#} == 2 )) && ${O[compare]}; then
	cmp-output_filelist_diff
	cmp-output_soname_diff
	cmp-output_abi_diff
	cmp-output_size_diff

	if [[ ${OUTPUT} ]]; then
		cmp-print_header "$@"
		msg "${OUTPUT%$'\n'}"
		${O[report]} && msg "------>${REPORT}"
	elif [[ ${REPORT} ]] && ${O[report]}; then
		cmp-print_header "$@"
		msg "------>${REPORT}"
	fi
fi

:

# vim: ts=4
