#!/bin/sh
#
# Spk-doctor - Diagnostic and integrity audit for spk installations.
# Read-only by default: never mutates the DB, the mirror or installed files.
# Heavy logging to ${logdir}/doctor-<ts>.log so a broken chroot leaves a
# trail to look at after the fact. Read the README before adding or
# modifying any code in spk!
#
# Copyright (C) SliTaz GNU/Linux - BSD License
# Author: See AUTHORS files
#
# NOTE: We deliberately do NOT source /usr/lib/slitaz/libspk.sh — it exits
# 1 when $PKGS_DB is missing, which is exactly one of the broken states
# we want to diagnose. We replicate its internal variables locally
# (keep in sync with libspk.sh top section).
. /lib/libtaz.sh 2>/dev/null || {
	echo "spk-doctor: missing /lib/libtaz.sh — chroot is severely broken" >&2
	exit 2
}
. /usr/lib/slitaz/libpkg.sh 2>/dev/null
# Under --root, read the TARGET chroot's slitaz.conf so SLITAZ_ARCH/RELEASE
# describe the rootfs being diagnosed, not the host (check_env cross-checks
# SLITAZ_ARCH against the kernel — with the host config it would report the
# host's arch for a chroot). Fall back to the host config if the target ships
# none; for root="" both resolve to the same file.
. "${root}/etc/slitaz/slitaz.conf" 2>/dev/null \
	|| . /etc/slitaz/slitaz.conf 2>/dev/null

# Mirror libspk.sh internal vars (read-only diagnostic).
: "${PKGS_DB:=/var/lib/tazpkg}"
: "${CACHE_DIR:=/var/cache/tazpkg}"
: "${CHECKSUM:=md5sum}"
: "${SUM:=md5}"
mirrorurl="${root}${PKGS_DB}/mirror"
mirrors="${root}${PKGS_DB}/mirrors"
installed="${root}${PKGS_DB}/installed"
pkgsinfo="${root}${PKGS_DB}/packages.info"
pkgsmd5="${root}${PKGS_DB}/packages.$SUM"
pkgsequiv="${root}${PKGS_DB}/packages.equiv"
pkgsup="${root}${PKGS_DB}/packages.up"
blocked="${root}${PKGS_DB}/blocked.list"
activity="${root}${PKGS_DB}/activity"
logdir="${root}/var/log/spk"
extradb="${root}${PKGS_DB}/extra"

#
# Usage
#

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

$(boldify $(gettext "Usage:")) $name [--section=LIST] [--options]

$(boldify $(gettext "Sections (default: all):"))
  env           $(gettext "Slitaz config, libspk/libtaz, busybox, spk tools")
  db            $(gettext "Receipts, files.list, md5sum in \$installed/")
  files         $(gettext "Each path of files.list exists on disk")
  mirror        $(gettext "Primary mirror URL + packages.info/md5/equiv/ID")
  extra         $(gettext "Extra mirrors in \$extradb/")
  cache         $(gettext "\$CACHE_DIR/ — empty/broken .tazpkg, orphans")
  deps          $(gettext "DEPENDS of each installed pkg are installed")
  equiv         $(gettext "packages.equiv parsable, targets installed")
  blocked       $(gettext "blocked.list sanity")
  logs          $(gettext "Activity + last per-pkg install/up logs")
  tmp           $(gettext "Orphan /tmp/spk/* and stale locks")
  tazpkg        $(gettext "Cross-check with cohabiting tazpkg DB")

$(boldify $(gettext "Depth:"))
  --deep        $(gettext "Recompute md5 of installed files; probe mirror URL")

$(boldify $(gettext "Repair:"))
  --fix         $(gettext "Apply safe cleanups (orphan dirs, broken symlinks, empty .tazpkg, /tmp orphans)")
  --yes         $(gettext "Skip the confirmation prompt before --fix")

$(boldify $(gettext "Output:"))
  --raw         $(gettext "Plain TSV on stdout (level<TAB>section<TAB>pkg<TAB>msg)")
  --trame       $(gettext "JSON object on stdout, errors as JSON on stderr")
  --quiet       $(gettext "Only print the summary line at end")
  --log=PATH    $(gettext "Write the full trace to PATH (default: \$logdir/doctor-<ts>.log)")

$(boldify $(gettext "Exit codes:"))
  0 ok / 1 warnings / 2 errors / 3 permission / 4 not implemented

EOT
	exit 0
}

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

#
# Args / globals
#

# --raw / --trame / --quiet / --deep / --section / --log are set by libtaz
# argv parsing (exported as shell vars). Possible name collisions checked:
# none of these clobber libspk internals.

# Selected sections (comma list) -> space list for case match.
known_sections="env db files mirror extra cache deps equiv blocked logs tmp tazpkg"
if [ -n "$section" ]; then
	sections=$(echo "$section" | tr ',' ' ')
	# Reject an unknown name up front. want_section() silently matches
	# nothing for a typo (e.g. 'file' for 'files' or 'dep' for 'deps'), so
	# spk-doctor would run zero checks and exit 0 — a misleadingly clean
	# bill of health. Fail with the valid list instead.
	for s in $sections; do
		case " $known_sections " in
			*" $s "*) ;;
			*) echo "spk-doctor: unknown section: $s" >&2
			   echo "valid sections: $known_sections" >&2
			   exit 2 ;;
		esac
	done
else
	sections="$known_sections"
fi

output=human
[ "$raw" ] && output=raw
[ "$trame" ] && output=trame

ts=$(date -u +%Y%m%d-%H%M%S)
if [ -z "$log" ]; then
	# logdir already carries ${root} (set above), so don't prefix it again:
	# "${root}${logdir}" doubled the root under --root=/mnt (log written to
	# /mnt/mnt/var/log/spk while check_logs inspects /mnt/var/log/spk).
	mkdir -p "$logdir" 2>/dev/null
	log="$logdir/doctor-${ts}.log"
fi

# Try to create the log; on perm denied, fall back to /tmp so we never lose
# the trace silently.
: > "$log" 2>/dev/null || {
	log="/tmp/spk-doctor-${ts}.log"
	: > "$log" 2>/dev/null || log=/dev/null
}

ok_count=0
info_count=0
warn_count=0
fail_count=0

# Buffer findings as TSV for --trame rendering at the very end.
findings_tsv=$(mktemp 2>/dev/null || echo "/tmp/spk-doctor-findings.$$")
: > "$findings_tsv"

cleanup() {
	rm -f "$findings_tsv" 2>/dev/null
}
trap cleanup EXIT INT TERM

#
# Logging primitives
#

# log_line "level" "section" "pkg" "message"
# - Always appends to $log with ISO-8601 timestamp.
# - Stdout depends on $output and $quiet.
log_line() {
	local lvl="$1" sect="$2" pkg="$3" msg="$4"
	local now
	now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
	printf '%s\t%s\t%s\t%s\t%s\n' \
		"$now" "$lvl" "$sect" "${pkg:--}" "$msg" >> "$log"
	printf '%s\t%s\t%s\t%s\n' \
		"$lvl" "$sect" "${pkg:--}" "$msg" >> "$findings_tsv"

	case "$lvl" in
		OK)   ok_count=$((ok_count + 1)) ;;
		INFO) info_count=$((info_count + 1)) ;;
		WARN) warn_count=$((warn_count + 1)) ;;
		FAIL) fail_count=$((fail_count + 1)) ;;
	esac

	[ "$quiet" ] && return 0

	case "$output" in
		raw)
			printf '%s\t%s\t%s\t%s\n' "$lvl" "$sect" "${pkg:--}" "$msg"
			;;
		trame)
			# Defer until end.
			;;
		*)
			local col
			case "$lvl" in
				OK)   col=32 ;;
				INFO) col=36 ;;
				WARN) col=33 ;;
				FAIL) col=31 ;;
				*)    col=37 ;;
			esac
			if [ -n "$pkg" ] && [ "$pkg" != "-" ]; then
				printf '  %s %s %s: %s\n' \
					"$(colorize $col "[$lvl]")" \
					"$sect" "$pkg" "$msg"
			else
				printf '  %s %s: %s\n' \
					"$(colorize $col "[$lvl]")" "$sect" "$msg"
			fi
			;;
	esac
}

ok()   { log_line OK   "$1" "$2" "$3"; }
info() { log_line INFO "$1" "$2" "$3"; }
warn() { log_line WARN "$1" "$2" "$3"; }
fail() { log_line FAIL "$1" "$2" "$3"; }

section_header() {
	local s="$1"
	# Always trace the section start to the log.
	printf '\n# === section: %s ===\n' "$s" >> "$log"
	[ "$quiet" ] && return 0
	case "$output" in
		raw|trame) ;;
		*)
			newline
			boldify "[$s]"
			separator
			;;
	esac
}

want_section() {
	local s="$1" w
	for w in $sections; do
		[ "$w" = "$s" ] && return 0
	done
	return 1
}

# JSON-escape (busybox sed): backslash, quote, then tab/cr, then folded nl.
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'
}

#
# Section: env
#

check_env() {
	want_section env || return 0
	section_header env

	info env "" "root=${root:-/}  arch=${SLITAZ_ARCH:-?}  release=${SLITAZ_RELEASE:-?}"
	info env "" "PKGS_DB=${PKGS_DB:-?}  CACHE_DIR=${CACHE_DIR:-?}  SUM=${SUM:-?}"

	# Cross-check kernel arch with slitaz.conf SLITAZ_ARCH. A silent
	# mismatch (e.g. SLITAZ_ARCH=i486 in an x86_64 chroot) makes spk-add
	# look up package files without the -<arch> suffix and every install
	# fails with a misleading "md5sum" error.
	# Check the rootfs arch can actually RUN on this kernel, not that they
	# are identical: a 32-bit rootfs runs on its 64-bit sibling (i486 on
	# x86_64, arm on aarch64) — the normal way SliTaz builds an i486 chroot
	# on an x86_64 host. Only a genuinely incompatible pair (arm rootfs on
	# x86, or a 64-bit rootfs on a 32-bit kernel) is a real mismatch. The old
	# strict-equality test false-FAILed every i486-on-x86_64 chroot.
	local kmach arch_ok arch_known
	kmach=$(uname -m 2>/dev/null)
	arch_ok=; arch_known=
	case "$SLITAZ_ARCH" in
		i486)   arch_known=yes; case "$kmach" in i[3456]86|x86_64) arch_ok=yes ;; esac ;;
		x86_64) arch_known=yes; case "$kmach" in x86_64) arch_ok=yes ;; esac ;;
		arm)    arch_known=yes; case "$kmach" in armv6l|armv7l|armhf|aarch64|arm64) arch_ok=yes ;; esac ;;
		arm64)  arch_known=yes; case "$kmach" in aarch64|arm64) arch_ok=yes ;; esac ;;
	esac
	if [ -z "$kmach" ]; then
		warn env "" "uname -m returned nothing — cannot cross-check SLITAZ_ARCH"
	elif [ -z "$SLITAZ_ARCH" ]; then
		fail env "" "SLITAZ_ARCH unset; kernel reports $kmach"
	elif [ -z "$arch_known" ]; then
		warn env "" "SLITAZ_ARCH=$SLITAZ_ARCH unrecognized; not cross-checked (kernel $kmach)"
	elif [ "$arch_ok" ]; then
		ok env "" "SLITAZ_ARCH=$SLITAZ_ARCH runs on uname -m=$kmach"
	else
		fail env "" "SLITAZ_ARCH=$SLITAZ_ARCH cannot run on uname -m=$kmach — check the rootfs arch"
	fi

	# libtaz / libpkg / libspk presence (path is host or chroot-rooted).
	local f
	for f in /lib/libtaz.sh /usr/lib/slitaz/libpkg.sh /usr/lib/slitaz/libspk.sh \
		/etc/slitaz/slitaz.conf
	do
		if [ -f "${root}$f" ]; then
			ok env "" "found ${root}$f"
		else
			fail env "" "missing ${root}$f"
		fi
	done

	# Spk tools.
	for f in spk spk-add spk-rm spk-up spk-ls spk-search spk-mirror \
		spk-convert
	do
		if [ -x "${root}/usr/bin/$f" ] || [ -x "${root}/usr/sbin/$f" ]; then
			ok env "$f" "installed"
		else
			warn env "$f" "not installed in /usr/{bin,sbin}"
		fi
	done

	# Shell flavor: spk is busybox ash. /bin/sh symlink target is useful.
	if [ -L "${root}/bin/sh" ]; then
		local tgt
		tgt=$(readlink "${root}/bin/sh")
		info env "" "/bin/sh -> $tgt"
	fi

	# Disk space for $PKGS_DB and $CACHE_DIR.
	if [ -d "${root}${PKGS_DB}" ]; then
		local du_db
		du_db=$(du -sh "${root}${PKGS_DB}" 2>/dev/null | awk '{print $1}')
		info env "" "DB size: ${du_db:-?}"
	fi
	if [ -d "${root}${CACHE_DIR}" ]; then
		local du_ca
		du_ca=$(du -sh "${root}${CACHE_DIR}" 2>/dev/null | awk '{print $1}')
		info env "" "Cache size: ${du_ca:-?}"
	fi
}

#
# Section: db
#

check_db() {
	want_section db || return 0
	section_header db

	if [ ! -d "$installed" ]; then
		fail db "" "missing $installed"
		return 0
	fi

	local d pkg pkg_var ver_var n=0
	for d in "$installed"/*/; do
		[ -d "$d" ] || continue
		n=$((n + 1))
		pkg=$(basename "$d")

		# Interrupted install left a .tx marker. The receipt either
		# never made it (= must roll back the deployed fs/) or is
		# present (= just clear the marker). spk-doctor --fix
		# applies the right cleanup; here we only report.
		if [ -f "$d/.tx" ]; then
			if [ -f "$d/receipt" ]; then
				warn db "$pkg" ".tx marker but receipt present (commit interrupted late)"
			else
				fail db "$pkg" ".tx marker, no receipt (commit interrupted mid-deploy)"
			fi
		fi
		if [ ! -f "$d/receipt" ]; then
			fail db "$pkg" "no receipt"
			continue
		fi

		# Source the receipt in a subshell to avoid clobbering our env.
		# We only need PACKAGE / VERSION.
		pkg_var=$(sh -c '. "$1/receipt" 2>/dev/null; echo "$PACKAGE"' _ "$d")
		ver_var=$(sh -c '. "$1/receipt" 2>/dev/null; echo "$VERSION"' _ "$d")

		if [ -z "$pkg_var" ]; then
			fail db "$pkg" "receipt does not define PACKAGE"
		elif [ "$pkg_var" != "$pkg" ]; then
			fail db "$pkg" "receipt PACKAGE=$pkg_var mismatches dir name"
		fi
		[ -z "$ver_var" ] && warn db "$pkg" "receipt has no VERSION"

		if [ ! -f "$d/files.list" ]; then
			fail db "$pkg" "no files.list"
		fi
		if [ ! -f "$d/md5sum" ]; then
			warn db "$pkg" "no md5sum"
		fi
	done

	info db "" "scanned $n receipts"
	[ "$n" -gt 0 ] || warn db "" "no installed packages at all"

	# installed.info drift, both directions. spk drives its DB off the
	# receipt dirs, but tazpkg / TazPanel read installed.info; spk-add and
	# spk-rm keep it in sync, so a mismatch means a corrupt or pre-spk DB.
	#   - entry in installed.info, no receipt dir = orphan (FAIL): hides
	#     from anything scanning $installed/*/, e.g. spk-up's upgrade scan.
	#   - receipt dir, no installed.info entry = index drift (WARN): the
	#     package is really installed but invisible to tazpkg's tools.
	local orphans=0 missing=0 p _rest
	if [ -f "$installed.info" ]; then
		while IFS='	' read p _rest; do
			[ -n "$p" ] || continue
			[ -d "$installed/$p" ] && continue
			fail db "$p" "in installed.info, no receipt dir (orphan DB entry)"
			orphans=$((orphans + 1))
		done < "$installed.info"
		for d in "$installed"/*/; do
			[ -d "$d" ] || continue
			p=$(basename "$d")
			awk -F'\t' -v p="$p" '$1==p{f=1} END{exit !f}' \
				"$installed.info" && continue
			warn db "$p" "receipt dir not in installed.info (index drift)"
			missing=$((missing + 1))
		done
	else
		warn db "" "installed.info missing (tazpkg index absent)"
	fi
	[ "$orphans" -gt 0 ] && info db "" "orphan DB entries: $orphans"
	[ "$missing" -gt 0 ] && info db "" "receipts not in installed.info: $missing"
}

#
# Section: files
#

check_files() {
	want_section files || return 0
	section_header files

	if [ ! -d "$installed" ]; then
		fail files "" "missing $installed"
		return 0
	fi

	local d pkg missing total miss_total=0
	for d in "$installed"/*/; do
		[ -d "$d" ] || continue
		pkg=$(basename "$d")
		[ -f "$d/files.list" ] || continue

		missing=0
		total=0
		# files.list uses absolute paths starting with "/".
		while IFS= read -r p; do
			[ -z "$p" ] && continue
			total=$((total + 1))
			# Skip directory entries (ends with "/") for the existence check.
			case "$p" in
				*/) continue ;;
			esac
			if [ ! -e "${root}$p" ] && [ ! -L "${root}$p" ]; then
				missing=$((missing + 1))
				warn files "$pkg" "missing $p"
			fi
		done < "$d/files.list"

		if [ "$missing" -gt 0 ]; then
			fail files "$pkg" "$missing/$total files missing"
			miss_total=$((miss_total + missing))
		fi

		# Optional deep md5 verification.
		if [ "$deep" ] && [ -f "$d/md5sum" ]; then
			local bad=0
			# md5sum format: "<sum>  <path>". Path is absolute.
			while read -r sum path; do
				[ -z "$sum" ] && continue
				[ -e "${root}$path" ] || continue
				local got
				got=$(${CHECKSUM:-md5sum} "${root}$path" 2>/dev/null | \
					awk '{print $1}')
				if [ -n "$got" ] && [ "$got" != "$sum" ]; then
					bad=$((bad + 1))
					warn files "$pkg" "md5 mismatch $path"
				fi
			done < "$d/md5sum"
			[ "$bad" -gt 0 ] && fail files "$pkg" "$bad files with md5 mismatch"
		fi
	done

	info files "" "total missing files: $miss_total"
}

#
# Section: mirror
#

check_mirror() {
	want_section mirror || return 0
	section_header mirror

	if [ ! -f "$mirrorurl" ]; then
		warn mirror "" "no mirror configured ($mirrorurl missing)"
		return 0
	fi
	local url
	url=$(cat "$mirrorurl" 2>/dev/null | head -1)
	info mirror "" "url=$url"

	local f
	for f in packages.info packages.md5 packages.equiv files-list.lzma \
		files-list.md5 ID
	do
		if [ -f "${root}${PKGS_DB}/$f" ]; then
			ok mirror "" "have $f"
		else
			warn mirror "" "missing local $f"
		fi
	done

	# ID vs ID.bak (used by recharge_lists to decide refresh).
	if [ -f "${root}${PKGS_DB}/ID" ] && [ -f "${root}${PKGS_DB}/ID.bak" ]; then
		if cmp -s "${root}${PKGS_DB}/ID" "${root}${PKGS_DB}/ID.bak"; then
			ok mirror "" "ID == ID.bak (lists fresh)"
		else
			info mirror "" "ID != ID.bak (upgrade pending or recharge needed)"
		fi
	fi

	# Probe URL on --deep.
	if [ "$deep" ] && [ -n "$url" ]; then
		if command -v wget >/dev/null 2>&1; then
			if wget -q --spider -T 8 "${url}/ID" 2>>"$log"; then
				ok mirror "" "reachable: ${url}/ID"
			else
				fail mirror "" "unreachable: ${url}/ID"
			fi
		elif command -v curl >/dev/null 2>&1; then
			if curl -fsI -m 8 "${url}/ID" >>"$log" 2>&1; then
				ok mirror "" "reachable: ${url}/ID"
			else
				fail mirror "" "unreachable: ${url}/ID"
			fi
		else
			warn mirror "" "no wget/curl to probe URL"
		fi
	fi

	# packages.info sanity: tab-separated. 10 fields on i486 (no arch
	# suffix), 11 on x86_64/arm where cook-pkgdb appends a noarch flag
	# (field 11: "0" = -any.tazpkg, "1" = -$ARCH.tazpkg).
	if [ -f "${root}${PKGS_DB}/packages.info" ]; then
		local lines bad
		lines=$(wc -l < "${root}${PKGS_DB}/packages.info")
		bad=$(awk -F'\t' 'NF != 10 && NF != 11 {n++} END{print n+0}' \
			"${root}${PKGS_DB}/packages.info")
		info mirror "" "packages.info: $lines lines"
		if [ "$bad" -gt 0 ]; then
			fail mirror "" "$bad malformed lines (NF not 10/11) in packages.info"
		else
			ok mirror "" "packages.info TSV well-formed"
		fi
	fi
}

#
# Section: extra
#

check_extra() {
	want_section extra || return 0
	section_header extra

	if [ ! -d "$extradb" ]; then
		info extra "" "no extra mirrors dir"
		return 0
	fi
	local d name n=0
	for d in "$extradb"/*/; do
		[ -d "$d" ] || continue
		n=$((n + 1))
		name=$(basename "$d")
		local f
		for f in packages.info packages.md5 mirror; do
			if [ -e "$d/$f" ]; then
				ok extra "$name" "have $f"
			else
				warn extra "$name" "missing $f"
			fi
		done
	done
	[ "$n" -eq 0 ] && info extra "" "no extra mirrors registered"
}

#
# Section: cache
#

check_cache() {
	want_section cache || return 0
	section_header cache

	local cache="${root}${CACHE_DIR}"
	if [ ! -d "$cache" ]; then
		warn cache "" "no $cache"
		return 0
	fi

	local total bytes broken empty orphan n=0
	total=0; broken=0; empty=0; orphan=0
	bytes=$(du -sh "$cache" 2>/dev/null | awk '{print $1}')
	info cache "" "size: ${bytes:-?}"

	# Scan .tazpkg entries — must use find (not shell glob), because
	# busybox ash glob silently drops broken symlinks: precisely the case
	# we want to flag.
	local f base
	find "$cache" -mindepth 1 -maxdepth 1 -name '*.tazpkg' \
		\( -type f -o -type l \) -print > "$findings_tsv.cache" 2>/dev/null
	while IFS= read -r f; do
		[ -z "$f" ] && continue
		total=$((total + 1))
		base=$(basename "$f")

		# Broken symlink: -L true, -e false.
		if [ -L "$f" ] && [ ! -e "$f" ]; then
			broken=$((broken + 1))
			warn cache "$base" "broken symlink -> $(readlink "$f")"
			continue
		fi

		# Zero-byte file — the bug fixed in rev 168. Such a file in the
		# cache would have crashed spk-add silently before the fix.
		if [ ! -s "$f" ]; then
			empty=$((empty + 1))
			fail cache "$base" "zero-byte .tazpkg (corrupt download?)"
			continue
		fi

		# Orphan check: file not listed in packages.md5. Only meaningful
		# when packages.md5 exists; otherwise we can't tell.
		if [ -f "$pkgsmd5" ]; then
			if ! grep -q "  ${base}\$" "$pkgsmd5" 2>/dev/null; then
				orphan=$((orphan + 1))
				warn cache "$base" "not in packages.md5 (orphan / stale version)"
			fi
		fi

		# Deep mode: recompute md5 and compare with packages.md5.
		if [ "$deep" ] && [ -f "$pkgsmd5" ]; then
			local got want
			got=$(${CHECKSUM:-md5sum} "$f" 2>/dev/null | awk '{print $1}')
			want=$(grep "  ${base}\$" "$pkgsmd5" 2>/dev/null | \
				awk '{print $1}')
			if [ -n "$want" ] && [ -n "$got" ] && [ "$got" != "$want" ]; then
				fail cache "$base" "md5 mismatch (got $got, expected $want)"
			fi
		fi
	done < "$findings_tsv.cache"
	rm -f "$findings_tsv.cache"

	info cache "" "$total .tazpkg in cache"
	[ "$broken" -gt 0 ] && fail cache "" "$broken broken symlinks"
	[ "$empty" -gt 0 ] && fail cache "" "$empty zero-byte files"
	[ "$orphan" -gt 0 ] && info cache "" "$orphan orphan(s) not in mirror"
	[ "$total" -gt 0 ] && [ "$broken" -eq 0 ] && [ "$empty" -eq 0 ] && \
		ok cache "" "cache structurally clean"
}

#
# Section: deps
#

check_deps() {
	want_section deps || return 0
	section_header deps

	[ -d "$installed" ] || { fail deps "" "no $installed"; return 0; }

	local d pkg deps_var dep miss
	for d in "$installed"/*/; do
		[ -d "$d" ] || continue
		[ -f "$d/receipt" ] || continue
		pkg=$(basename "$d")
		deps_var=$(sh -c '. "$1/receipt" 2>/dev/null; echo "$DEPENDS"' _ "$d")
		[ -z "$deps_var" ] && continue
		miss=
		for dep in $deps_var; do
			if [ ! -d "$installed/$dep" ]; then
				# Could be satisfied via packages.equiv (PROVIDE/alias).
				# The line is "dep=alt1 alt2 …": check EVERY alternative, not
				# just the first (a plain `sed 's/ .*//'` dropped alt2+ and
				# false-flagged a dep whose 2nd provider was the installed
				# one). Also handle the "alternative:newname" form the way
				# libspk's equivalent_pkg does — the dep is satisfied when the
				# alternative (before the ':') is installed.
				if [ -f "$pkgsequiv" ] && \
					grep -q "^${dep}=" "$pkgsequiv" 2>/dev/null
				then
					local alts alt sat=
					alts=$(grep "^${dep}=" "$pkgsequiv" | head -1 | \
						sed 's/^[^=]*=//')
					for alt in $alts; do
						case "$alt" in
							*:*) alt=${alt%%:*} ;;
						esac
						[ -d "$installed/$alt" ] && { sat=yes; break; }
					done
					[ "$sat" ] && continue
				fi
				miss="$miss $dep"
			fi
		done
		if [ -n "$miss" ]; then
			fail deps "$pkg" "missing deps:$miss"
		fi
	done
}

#
# Section: equiv
#

check_equiv() {
	want_section equiv || return 0
	section_header equiv

	if [ ! -f "$pkgsequiv" ]; then
		info equiv "" "no $pkgsequiv"
		return 0
	fi
	local bad=0 total=0
	while IFS= read -r line; do
		[ -z "$line" ] && continue
		case "$line" in '#'*) continue ;; esac
		total=$((total + 1))
		echo "$line" | grep -q '=' || { bad=$((bad + 1)); continue; }
	done < "$pkgsequiv"
	info equiv "" "$total entries, $bad malformed"
	[ "$bad" -gt 0 ] && warn equiv "" "$bad lines without '=' delimiter"
}

#
# Section: blocked
#

check_blocked() {
	want_section blocked || return 0
	section_header blocked

	if [ ! -f "$blocked" ]; then
		info blocked "" "no blocked.list"
		return 0
	fi
	local n bad=0
	n=$(wc -l < "$blocked")
	info blocked "" "$n blocked entries"
	while IFS= read -r p; do
		[ -z "$p" ] && continue
		if [ ! -d "$installed/$p" ]; then
			warn blocked "$p" "blocked but not installed"
			bad=$((bad + 1))
		fi
	done < "$blocked"
	[ "$bad" -eq 0 ] && [ "$n" -gt 0 ] && ok blocked "" "all blocked pkgs are installed"
}

#
# Section: logs
#

check_logs() {
	want_section logs || return 0
	section_header logs

	if [ -f "$activity" ]; then
		local last
		last=$(tail -3 "$activity" 2>/dev/null | tr '\n' '|')
		info logs "" "activity last: ${last:-empty}"
	else
		info logs "" "no activity file"
	fi

	# Mirror metadata snapshots taken by recharge_lists.
	local snapdir="${root}/var/cache/spk/snapshots"
	if [ -d "$snapdir" ]; then
		local nsnap last
		nsnap=$(ls -1 "$snapdir" 2>/dev/null | wc -l)
		last=$(ls -1t "$snapdir" 2>/dev/null | head -1)
		if [ "$nsnap" -gt 0 ]; then
			info logs "" "$nsnap mirror snapshots, latest: $last"
		fi
	fi

	if [ -d "$logdir" ]; then
		# Surface the most recent install/up logs across all packages.
		local recent
		# Exclude our own doctor-*.log: we'd self-flag on every FAIL finding.
		recent=$(find "$logdir" -type f -name '*.log' \
			-not -name 'doctor-*.log' 2>/dev/null | head -50)
		if [ -n "$recent" ]; then
			local f p
			# Grep err markers from each. Feed the loop with a here-doc, not
			# `echo "$recent" | while`: the pipe ran the loop in a subshell,
			# so every warn() (which bumps the global warn_count) was lost —
			# the WARN lines printed but the summary reported warn=0 and the
			# exit code stayed 0 instead of 1.
			while IFS= read -r f; do
				[ -z "$f" ] && continue
				if grep -qi 'error\|failed\|cannot\|denied' "$f" 2>/dev/null; then
					p=$(basename "$(dirname "$f")")
					warn logs "$p" "log has error markers: $f"
				fi
			done <<EOF
$recent
EOF
		else
			info logs "" "no per-pkg logs in $logdir"
		fi
	else
		info logs "" "no $logdir"
	fi
}

#
# Section: tmp
#

check_tmp() {
	want_section tmp || return 0
	section_header tmp

	if [ -d "${root}/tmp/spk" ]; then
		local n
		n=$(find "${root}/tmp/spk" -mindepth 1 -maxdepth 1 2>/dev/null | \
			wc -l)
		if [ "$n" -gt 0 ]; then
			warn tmp "" "$n stale entries in ${root}/tmp/spk (orphans of crashed runs)"
			find "${root}/tmp/spk" -mindepth 1 -maxdepth 1 -exec ls -ld {} \; \
				>> "$log" 2>&1
		else
			ok tmp "" "no orphans in ${root}/tmp/spk"
		fi
	else
		ok tmp "" "no ${root}/tmp/spk directory"
	fi
}

#
# Section: tazpkg
#

check_tazpkg() {
	want_section tazpkg || return 0
	section_header tazpkg

	# tazpkg shares $PKGS_DB historically, so the cohabitation check is
	# really about presence/divergence of the legacy tool, not parallel DBs.
	if [ -x "${root}/usr/bin/tazpkg" ]; then
		local ver
		ver=$(${root}/usr/bin/tazpkg --help 2>&1 | head -1)
		info tazpkg "" "tazpkg present: $ver"
	else
		info tazpkg "" "tazpkg not installed (spk is the only manager)"
	fi
}

#
# Repair (--fix)
#

# Each fix is its own one-shot helper: print what would be touched
# (--dry-run-ish since the report goes to stdout too), then do it if
# the user said yes. All fixes are read-then-stat-then-act so they
# stay safe even if the audit ran a while ago.

# 0. Interrupted installs marked by .tx (atomicity recovery, rev 180).
# Two cases:
#   - receipt present  => commit succeeded, just drop .tx
#   - receipt absent   => commit failed mid-deploy; if files.list is
#                         readable, rm the deployed paths from $root,
#                         then rm -rf the half-installed dir
fix_interrupted_installs() {
	[ -d "$installed" ] || return 0
	local d pkg n_clear=0 n_roll=0 path
	for d in "$installed"/*/; do
		[ -d "$d" ] || continue
		[ -f "$d/.tx" ] || continue
		pkg=$(basename "$d")
		if [ -f "$d/receipt" ]; then
			rm -f "$d/.tx" && n_clear=$((n_clear + 1)) && \
				echo "  - cleared late-commit .tx for $pkg"
			continue
		fi
		# Rollback deploy. If files.list survived from the staging
		# step, use it to remove deployed paths cleanly.
		if [ -f "$d/files.list" ]; then
			while IFS= read -r path; do
				[ -z "$path" ] && continue
				case "$path" in */) continue ;; esac
				rm -f "${root}${path}" 2>/dev/null
			done < "$d/files.list"
		fi
		# Strip any stale checksum entry for exactly this package. A plain
		# sed "/  ${pkg}-/d" is an unanchored prefix match: rolling back
		# "xorg" would also delete every "xorg-server-…" line (and "." in the
		# name would wildcard) — so the fixer itself created the drift it is
		# meant to repair. Match field 2 (filename) by exact package name:
		# "$pkg-" as a fixed-string prefix followed by a version digit, which
		# tells xorg- from xorg-server-.
		local md5f="${root}${PKGS_DB}/installed.${SUM:-md5}"
		if [ -f "$md5f" ]; then
			awk -v p="$pkg" '{
				n=$2
				if (index(n, p"-")==1 && substr(n, length(p)+2, 1) ~ /[0-9]/) next
				print
			}' "$md5f" > "$md5f.$$" 2>/dev/null && mv "$md5f.$$" "$md5f"
		fi
		rm -rf "$d" && n_roll=$((n_roll + 1)) && \
			echo "  - rolled back interrupted install of $pkg"
	done
	[ "$n_clear" -gt 0 ] && echo "  fix_interrupted_installs: $n_clear cleared"
	[ "$n_roll"  -gt 0 ] && echo "  fix_interrupted_installs: $n_roll rolled back"
}

# 1. installed/<pkg>/ dirs without a receipt (ghost install artifacts).
fix_ghost_installed() {
	[ -d "$installed" ] || return 0
	local d n=0
	for d in "$installed"/*/; do
		[ -d "$d" ] || continue
		[ -f "$d/receipt" ] && continue
		# Only rmdir — never rm -rf — so a non-empty dir is never
		# touched by accident.
		if rmdir "$d" 2>/dev/null; then
			n=$((n + 1))
			echo "  - removed ghost $d"
		else
			echo "  ! ghost dir not empty, leaving: $d"
		fi
	done
	[ "$n" -gt 0 ] && echo "  fix_ghost_installed: $n removed"
}

# 2. Broken cache symlinks.
fix_broken_cache() {
	local cache="${root}${CACHE_DIR}"
	[ -d "$cache" ] || return 0
	local n=0
	find "$cache" -mindepth 1 -maxdepth 1 -type l 2>/dev/null \
	| while read -r f; do
		[ -e "$f" ] && continue
		rm -f "$f" && echo "  - unlinked broken $f" && n=$((n + 1))
	done
	# Loop ran in subshell; final count via re-count.
	echo "  fix_broken_cache: done"
}

# 3. Zero-byte .tazpkg in cache.
fix_empty_tazpkg() {
	local cache="${root}${CACHE_DIR}"
	[ -d "$cache" ] || return 0
	find "$cache" -mindepth 1 -maxdepth 1 -name '*.tazpkg' \
		-type f -size 0 2>/dev/null \
	| while read -r f; do
		rm -f "$f" && echo "  - removed empty $f"
	done
	echo "  fix_empty_tazpkg: done"
}

# 4. /tmp/spk/* orphans (crashed install temp dirs).
fix_tmp_orphans() {
	local tmp="${root}/tmp/spk"
	[ -d "$tmp" ] || return 0
	local n=0
	for d in "$tmp"/*; do
		[ -e "$d" ] || continue
		rm -rf "$d" && n=$((n + 1))
	done
	[ "$n" -gt 0 ] && echo "  fix_tmp_orphans: $n removed"
}

# 5. mirror metadata where the original is missing but a .bak is
#    present — exactly the failure mode of an interrupted recharge.
fix_recharge_leftovers() {
	local db="${root}${PKGS_DB}"
	[ -d "$db" ] || return 0
	local f base n=0
	for f in "$db"/*.bak; do
		[ -f "$f" ] || continue
		base="${f%.bak}"
		if [ ! -f "$base" ] && [ -f "$f" ]; then
			cp -f "$f" "$base" && n=$((n + 1)) && \
				echo "  - restored $(basename "$base") from .bak"
		fi
	done
	[ "$n" -gt 0 ] && echo "  fix_recharge_leftovers: $n restored"
}

run_fixes() {
	newline
	boldify "$(gettext 'Spk Doctor — repair')"
	separator
	echo "  Will apply these safe cleanups:"
	echo "    0. Recover interrupted installs (.tx markers)"
	echo "    1. Remove ghost \$installed/<pkg>/ dirs with no receipt"
	echo "    2. Unlink broken cache symlinks"
	echo "    3. Remove zero-byte .tazpkg in cache"
	echo "    4. Remove /tmp/spk/* orphans"
	echo "    5. Restore missing mirror metadata from .bak (if any)"
	newline
	if [ -z "$yes" ]; then
		printf "%s " "$(gettext 'Proceed? [y/N]')"
		read ans
		case "$ans" in
			y|Y|yes|YES) ;;
			*) echo "  aborted."; return 0 ;;
		esac
	fi
	echo "# repair $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$log"
	fix_interrupted_installs | tee -a "$log"
	fix_ghost_installed      | tee -a "$log"
	fix_broken_cache         | tee -a "$log"
	fix_empty_tazpkg         | tee -a "$log"
	fix_tmp_orphans          | tee -a "$log"
	fix_recharge_leftovers   | tee -a "$log"
	separator
}

#
# Final summary
#

render_summary_human() {
	newline
	separator
	boldify "$(gettext 'Summary')"
	printf '  ok=%s  info=%s  warn=%s  fail=%s\n' \
		"$ok_count" "$info_count" "$warn_count" "$fail_count"
	echo "  log: $log"
}

render_summary_raw() {
	printf 'summary\tok=%s\tinfo=%s\twarn=%s\tfail=%s\tlog=%s\n' \
		"$ok_count" "$info_count" "$warn_count" "$fail_count" "$log"
}

render_trame() {
	# Build a single JSON object: { summary:{}, findings:[{level,section,pkg,message}, ...] }
	printf '{"command":"doctor","summary":{"ok":%s,"info":%s,"warn":%s,"fail":%s,"log":"%s"},"findings":[' \
		"$ok_count" "$info_count" "$warn_count" "$fail_count" \
		"$(json_escape "$log")"
	local first=1
	# Read findings_tsv: lvl<tab>sect<tab>pkg<tab>msg
	while IFS="	" read -r lvl sect pkg msg; do
		[ -z "$lvl" ] && continue
		[ "$first" -eq 1 ] || printf ','
		first=0
		printf '{"level":"%s","section":"%s","package":"%s","message":"%s"}' \
			"$(json_escape "$lvl")" "$(json_escape "$sect")" \
			"$(json_escape "$pkg")" "$(json_escape "$msg")"
	done < "$findings_tsv"
	printf ']}\n'
}

#
# Main
#

# Log header.
{
	echo "# spk-doctor $(date -u +%Y-%m-%dT%H:%M:%SZ)"
	echo "# argv: $0 $*"
	echo "# root=${root:-/}  sections=$sections  deep=${deep:-no}"
	echo "# slitaz_arch=${SLITAZ_ARCH:-?}  release=${SLITAZ_RELEASE:-?}"
} >> "$log"

if [ "$output" = human ] && [ -z "$quiet" ]; then
	newline
	boldify "$(gettext 'Spk Doctor')"
	separator
	echo "  root=${root:-/}  sections=$sections  log=$log"
fi

check_env
check_db
check_files
check_mirror
check_extra
check_cache
check_deps
check_equiv
check_blocked
check_logs
check_tmp
check_tazpkg

case "$output" in
	raw)   render_summary_raw ;;
	trame) render_trame ;;
	*)     [ -z "$quiet" ] && render_summary_human
	       [ -n "$quiet" ] && render_summary_raw
	       ;;
esac

# Apply --fix after the audit and summary so the user has seen the
# findings before agreeing. Skipped in raw/trame modes to keep their
# stdout machine-parseable.
if [ "$fix" ] && [ "$output" = human ]; then
	run_fixes
fi
if [ "$fix" ] && [ "$output" != human ]; then
	echo "spk-doctor: --fix is only available in human mode" >&2
fi

# Exit code: 2 if any FAIL, 1 if only WARN, 0 otherwise.
if [ "$fail_count" -gt 0 ]; then
	exit 2
elif [ "$warn_count" -gt 0 ]; then
	exit 1
fi
exit 0
