#!/bin/bash
#
# Copyright (C) 2013-2015, 2017, 2020, 2023, 2026  Etersoft
# Copyright (C) 2013-2015, 2017, 2020, 2023, 2026  Vitaly Lipatov <lav@etersoft.ru>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

PROGDIR=$(dirname "$0")
[ "$PROGDIR" = "." ] && PROGDIR=$(pwd)
ERCAT="$PROGDIR/$(basename "$0" | sed 's|erc$|ercat|')"

# will replaced to /usr/share/erc during install
SHAREDIR=$(dirname "$0")

load_helper()
{
    local CMD="$SHAREDIR/$1"
    [ -r "$CMD" ] || fatal "Have no $CMD helper file"
    . "$CMD"
}

load_helper erc-sh-functions
load_helper erc-sh-archive

check_tty

# 1.zip tar:  -> 1.tar
build_target_name()
{
	is_target_format $2 && echo $(dirname "$1")/$(get_archive_name "$1").${2/:/} && return
	echo "$1"
        return 1
}


# TODO: list of $HAVE_7Z supported (see list_formats)

# target file1 [file2...]
create_archive()
{
	local arc="$1"
	shift
	if have_patool ; then
		docmd patool $verbose create "$arc" "$@"
		return
	fi

	# FIXME: get type by ext only
	local type="$(get_archive_type "$arc")"
	case "$type" in
		tar)
			#docmd $HAVE_7Z a -l $arc "$@"
			docmd tar cvf "$arc" "$@"
			;;
		*)
			# https://bugzilla.altlinux.org/49852
			# FIXME: creating .tar.* (.tar.gz) is not supported
			docmd $HAVE_7Z a -snl "$arc" "$@"
			#fatal "Not yet supported creating of $type archives"
			;;
	esac
}

# Extract archive with 7z into subdir
__7z_snld_flag=""
__7z_snld_checked=""

# Get -snld flag if supported (7z 25.01+ rejects symlinks with ../ by default)
get_7z_snld()
{
	if [ -z "$__7z_snld_checked" ] ; then
		__7z_snld_checked=1
		local ver
		ver=$($HAVE_7Z 2>&1 | sed -n 's/.*[Zz]ip.*[[:space:]]\([0-9]*\)\.\([0-9]*\).*/\1\2/p' | head -1)
		[ "${ver:-0}" -ge 2501 ] && __7z_snld_flag="-snld"
	fi
	echo $__7z_snld_flag
}

extract_7z_to_subdir()
{
	local arc="$1"
	local subdir="$2"
	mkdir -p "$subdir" && cd "$subdir" || fatal
	docmd $HAVE_7Z x -y $(get_7z_snld) "$arc"
}

# Extract squashfs-based archives (squashfs, snap)
extract_squashfs_image()
{
	local arc="$1"
	local subdir="$2"
	if [ -z "$ERC_USE_7Z_SQUASHFS" ] && is_command unsquashfs ; then
		docmd unsquashfs -d "$subdir" "$arc"
	else
		extract_7z_to_subdir "$arc" "$subdir"
	fi
}

# Extract makeself payload as tar stream to stdout
extract_makeself_tar()
{
	local arc="$1"

	# Extract header line count
	# New makeself (2.4+): skip="715" variable
	# Old makeself: head -n 588 (literal number)
	local lines
	lines=$(sed -n 's/^skip="\{0,1\}\([0-9]\{1,\}\)"\{0,1\}/\1/p' "$arc" | head -1)
	if [ -z "$lines" ] ; then
		lines=$(grep -am1 -oP '(?<=head -n )\d+' "$arc")
	fi
	[ -n "$lines" ] || fatal "Can't find header line count in makeself archive $arc"

	# Calculate byte offset of payload
	local offset
	offset=$(head -n "$lines" "$arc" | wc -c | tr -d " ")

	# Detect compression method for info message
	local compress
	compress=$(sed -n 's/^.*Compression: \(.*\)/\1/p' "$arc" | head -1)
	if [ -z "$compress" ] ; then
		compress=$(sed -n "s/^COMPRESS=//p" "$arc" | head -1 | tr -d "'\"")
	fi
	echo "Extracting makeself archive (compression: ${compress:-gzip}, header: $lines lines, offset: $offset bytes)" >&2

	# Extract payload, auto-detect compression via ercat
	dd if="$arc" ibs="$offset" skip=1 obs=1024 conv=sync 2>/dev/null | "$ERCAT"
}

# Extract makeself self-extracting archives (.run)
extract_makeself()
{
	local arc="$1"
	local subdir="$2"
	mkdir -p "$subdir" && cd "$subdir" || fatal
	extract_makeself_tar "$arc" | extract_tar_stdin
}

# Extract AppImage archives
extract_appimage()
{
	local arc="$1"
	local subdir="$2"

	# Try unsquashfs with offset
	if is_command unsquashfs ; then
		local offset
		chmod +x "$arc" 2>/dev/null
		offset="$("$arc" --appimage-offset 2>/dev/null)"
		# Fallback: find squashfs magic (for cross-arch AppImages)
		if [ -z "$offset" ] ; then
			offset=$(LC_ALL=C grep -aboP 'hsqs' "$arc" 2>/dev/null | head -1 | cut -d: -f1)
		fi
		if [ -n "$offset" ] ; then
			if docmd unsquashfs -o "$offset" -d "$subdir" "$arc" ; then
				return 0
			fi
		fi
	fi

	# Fallback to 7z
	# Note: 7zz 26+ replaces symlinks containing ".." with empty files
	# and returns exit code 2. Install squashfs-tools for full extraction.
	extract_7z_to_subdir "$arc" "$subdir"
	local ret=$?
	if [ "$ret" -ne 0 ] ; then
		fatal "7z could not fully extract AppImage (some symlinks are missing). Install squashfs-tools for better results."
	fi

	# Fallback to --appimage-extract (disabled by default)
	#if [ -x "$arc" ] || chmod +x "$arc" ; then
	#	if "$arc" --appimage-extract >/dev/null 2>&1 ; then
	#		if [ -d "squashfs-root" ] ; then
	#			[ "$subdir" != "squashfs-root" ] && mv squashfs-root "$subdir"
	#			return 0
	#		fi
	#	fi
	#fi
}

# TODO: move to patool
# Extract special archive types that are not supported by patool
extract_special_archive()
{
	local arc="$1"
	local type="$2"
	local orig_dir="$PWD"
	local subdir
	local use_tdir=""
	if [ -n "$here" ] || [ -n "$flat" ] ; then
		subdir="${extract_dir:-.}"
	elif [ -n "$extract_dir" ] ; then
		subdir=$(mktemp -d "$(dirname "$extract_dir")/UXXXXXXXX")
		use_tdir=1
	else
		subdir=$(mktemp -d "$(pwd)/UXXXXXXXX")
		use_tdir=1
	fi

	local flat_tdir
	if [ -n "$flat" ] ; then
		flat_tdir=$(mktemp -d "$(realpath "$subdir")/UXXXXXXXX")
		subdir="$flat_tdir"
	fi

	case "$type" in
		exe|dll)
			extract_7z_to_subdir "$arc" "$subdir"
			;;
		run)
			extract_makeself "$arc" "$subdir"
			;;
		AppImage|appimage)
			extract_appimage "$arc" "$subdir"
			;;
		squashfs|snap)
			extract_squashfs_image "$arc" "$subdir"
			;;
		*)
			[ -n "$use_tdir" ] && rmdir "$subdir"
			return 1
			;;
	esac
	cd "$orig_dir"

	if [ -n "$flat_tdir" ] ; then
		local target_dir="${extract_dir:-$orig_dir}"
		find "$flat_tdir" -type f -exec mv -t "$target_dir" -- {} +
		rm -rf "$flat_tdir"
	elif [ -n "$use_tdir" ] ; then
		if [ -n "$extract_dir" ] ; then
			move_to_target_dir "$subdir" "$extract_dir"
		else
			local target_name="$(get_archive_name "$arc")"
			if [ -d "$target_name" ] ; then
				move_to_target_dir "$subdir" "$target_name"
			else
				move_from_tdir "$subdir" "$target_name"
			fi
		fi
	fi

	exit
}

extract_by_type()
{
	local arc="$1"
	local type="$2"
	shift 2

	# TODO: check if there is only one file?
	# use subdir if there is no subdir in archive
	case "$type" in
		tar.gz|tgz)
			is_command gzip || fatal "Could not find gzip package. Please install gzip package and retry."
			extract_command "tar -xhzf" "$arc"
			;;
		tar.xz|txz|tar.lzma)
			is_command xz || fatal "Could not find xz package. Please install xz package and retry."
			extract_command "tar -xhJf" "$arc"
			;;
		tar.zst)
			is_command zstd || fatal "Could not find zstd package. Please install zstd package and retry."
			extract_command "tar -I zstd -xhf" "$arc"
			;;
		tar.bz2|tbz2)
			is_command bunzip2 || fatal "Could not find bzip2 package. Please install bzip2 package and retry."
			extract_command "tar -xhjf" "$arc"
			;;
		tar)
			extract_command "tar -xhf" "$arc"
			;;
		*)
			docmd $HAVE_7Z x -y "$arc" "$@"
			#fatal "Not yet supported extracting of $type archives"
			;;
	esac
}

is_system_dir()
{
	case "$1" in
		opt|usr|etc|var|bin|sbin|lib|lib64|home|root|srv|tmp|run|mnt|media|boot|dev|proc|sys)
			return 0 ;;
	esac
	return 1
}

# Move extracted contents from temp dir to existing target directory (-C)
# Single non-system directory is unwrapped (contents moved directly into target)
move_to_target_dir()
{
	local tdir="$1"
	local target="$2"

	shopt -s nullglob dotglob
	local items=("$tdir"/*)
	shopt -u nullglob dotglob

	if [ ${#items[@]} -eq 1 ] && [ -d "${items[0]}" ] ; then
		if is_system_dir "$(basename "${items[0]}")" ; then
			mv -- "${items[0]}" "$target/"
		else
			# single directory: move its contents into target
			shopt -s dotglob
			mv -- "${items[0]}"/* "$target/"
			shopt -u dotglob
			rmdir -- "${items[0]}"
		fi
	else
		mv -- "${items[@]}" "$target/"
	fi
	rmdir "$tdir"
}

# Move extracted contents from temp dir to current directory (normal extraction)
# Single directory is renamed to subdir, single file goes to current dir
move_from_tdir()
{
	local tdir="$1"
	local subdir="$2"

	shopt -s nullglob dotglob
	local items=("$tdir"/*)
	shopt -u nullglob dotglob

	if [ ${#items[@]} -eq 1 ] && [ -e "${items[0]}" ] ; then
		if [ -d "${items[0]}" ] ; then
			if is_system_dir "$(basename "${items[0]}")" ; then
				mkdir -p "$subdir"
				mv -- "${items[0]}" "$subdir/"
			else
				mv -- "${items[0]}" "$subdir"
			fi
		else
			mv -- "${items[0]}" .
		fi
		rmdir "$tdir"
	else
		mv -- "$tdir" "$subdir"
	fi
}

extract_archive()
{
	local arc="$1"
	shift

	local type="$(get_archive_type "$arc")"
	[ -n "$type" ] || fatal "Can't recognize type of $arc."

	arc="$(realpath -s "$arc")"

	extract_special_archive "$arc" "$type"

	# --here: extract directly to target directory without subdir logic
	local target_dir="${extract_dir}"
	[ -n "$here" ] && [ -z "$target_dir" ] && target_dir="."

	if [ -n "$here" ] && [ -n "$target_dir" ] && [ -z "$flat" ] ; then
		if have_patool ; then
			docmd patool $verbose extract --outdir "$target_dir" "$arc"
		else
			cd "$target_dir" || fatal
			extract_by_type "$arc" "$type" "$@"
		fi
		return
	fi

	# -C: extract to temp dir, apply subdir logic, move to target
	if [ -n "$extract_dir" ] && [ -z "$here" ] && [ -z "$flat" ] ; then
		tdir=$(mktemp -d "$(dirname "$extract_dir")/UXXXXXXXX")
		local res
		if have_patool ; then
			docmd patool $verbose extract --outdir "$tdir" "$arc" "$@"
			res=$?
		else
			cd "$tdir" || fatal
			extract_by_type "$arc" "$type" "$@"
			res=$?
			cd - >/dev/null
		fi
		if [ "$res" -eq 0 ] ; then
			move_to_target_dir "$tdir" "$extract_dir"
		else
			rm -rf "$tdir"
		fi
		return $res
	fi

	tdir=$(mktemp -d $(pwd)/UXXXXXXXX)
	local res
	if have_patool ; then
		docmd patool $verbose extract --outdir "$tdir" "$arc" "$@"
		res=$?
	else
		cd "$tdir" || fatal
		extract_by_type "$arc" "$type" "$@"
		res=$?
		cd - >/dev/null
	fi

	if [ "$res" -eq 0 ] ; then
		if [ -n "$flat" ] ; then
			# --flat: move all files to target, stripping directory structure
			local dest="${target_dir:-.}"
			find "$tdir" -type f -exec mv -t "$dest" -- {} +
			rm -rf "$tdir"
		else
			local target_name="$(get_archive_name "$arc")"
			if [ -d "$target_name" ] ; then
				move_to_target_dir "$tdir" "$target_name"
			else
				move_from_tdir "$tdir" "$target_name"
			fi
		fi
	fi

	return $res
}

list_archive()
{
	local arc="$1"
	shift

	# TODO: move to patool
	if [ "$(get_archive_type "$arc" 2>/dev/null)" = "exe" ] ; then
		docmd $HAVE_7Z l "$arc" || fatal
		return
	fi

	if have_patool ; then
		docmd patool $verbose list "$arc" "$@"
		return
	fi

	local type="$(get_archive_type "$arc")"
	case "$type" in
		*)
			docmd $HAVE_7Z l "$arc" "$@"
			#fatal "Not yet supported listing of $type archives"
			;;
	esac

}

test_archive()
{
	local arc="$1"
	shift

	# TODO: move to patool
	if [ "$(get_archive_type "$arc" 2>/dev/null)" = "exe" ] ; then
		docmd $HAVE_7Z t "$arc" || fatal
		return
	fi

	if have_patool ; then
		docmd patool $verbose test "$arc" "$@"
		return
	fi

	local type="$(get_archive_type "$arc")"
	case "$type" in
		*)
			docmd $HAVE_7Z t "$arc" "$@"
			#fatal "Not yet supported test of $type archives"
			;;
	esac

}

__repack_via_tmp()
{
	sfile="$(realpath -s "$1")"
	dfile="$(realpath -s "$2")"
	ddir="$(dirname "$dfile")"
	tdir="$(mktemp -d "$ddir/UXXXXXXXX")" && cd "$tdir" || fatal
	trap 'rm -fr "$tdir"' EXIT
	extract_archive "$sfile" || fatal
	create_archive "$dfile" "."
	#cd - >/dev/null
	#rm -fr "$tdir"
}

# Repack special archive types that are not supported by patool
repack_special_archive()
{
	local ftype="$(get_archive_type "$1")"
	case "$ftype" in
		run)
			# makeself payload is already tar, just decompress
			extract_makeself_tar "$1" > "$2"
			exit
			;;
		exe|dll|AppImage|appimage|squashfs|snap)
			__repack_via_tmp "$1" "$2"
			exit
			;;
	esac
}

repack_archive()
{
	repack_special_archive "$1" "$2"

	if have_patool ; then
		docmd patool $verbose repack "$1" "$2"
		return
	fi

	# TODO: if both have tar, try unpack | pack

	local ftype="$(get_archive_type "$1")"
	local ttype="$(get_archive_type "$2")"
	case "$ftype-$ttype" in
		tar.*-tar|tgz-tar)
			docmd $HAVE_7Z x -so "$1" > "$2"
			;;
		tar-tar.*)
			docmd $HAVE_7Z a -si "$2" < "$1"
			;;
		tar.*-tar.*)
			docmd $HAVE_7Z x -so "$1" | $HAVE_7Z a -si "$2"
			;;
		*)
			__repack_via_tmp "$1" "$2"
			;;
	esac

}


phelp()
{
	echo "$Descr
$Usage
 Commands:
$(get_help HELPCMD)

 Options:
$(get_help HELPOPT)

 Extraction rules:
    Single file in archive     -> extracted to current directory
    Single directory in archive -> renamed to BASENAME/
    System dir (opt, usr, etc) -> wrapped in BASENAME/ (not renamed)
    Multiple files or dirs     -> extracted to BASENAME/ subdirectory
    Use --here to skip creating subdirectory, --flat to strip all paths

 Examples:
    # erc dir - pack dir to dirname.zip
    # erc a archive.zip file(s)... - pack files to archive.zip
    # erc [x] archive.zip - unpack
    # unerc archive.zip - unpack
    # erc [repack] archive1.zip... archive2.rar 7z: - repack all to 7z
    # erc -f [repack] archive.zip archive.7z - force repack zip to 7z (override target in anyway)
    # erc -C dir archive.zip - extract archive directly to dir
    # erc --here archive.zip - extract as-is without creating subdirectory
    # erc --flat archive.zip - extract all files stripping directory structure
    # erc file/dir zip: - pack file to zip
    # erc basename archive.tar.gz - print predicted directory name (archive)
"
}

print_version()
{
        echo "Etersoft archive manager version @VERSION@"
        echo "Copyright (c) Etersoft 2013-2026"
        echo "This program may be freely redistributed under the terms of the GNU AGPLv3."
}

progname="${0##*/}"

Usage="Usage: $progname [options] [<command>] [params]..."
Descr="erc - universal archive manager"


force=
target=
extract_dir=
verbose=--verbose
quiet=
use_7z=
use_patool=
here=
flat=

if [ -z "$*" ] ; then
    echo "Etersoft archive manager version @VERSION@" >&2
    echo "Run $0 --help to get help" >&2
    exit 1
fi

while [ -n "$1" ] ; do
case "$1" in
    -h|--help|help)       # HELPOPT: this help
        phelp
        exit
        ;;
    -V|--version)         # HELPOPT: print version
        print_version
        exit
        ;;
    -q|--quiet)           # HELPOPT: be silent
        verbose=
        quiet=1
        ;;
    -f|--force)           # HELPOPT: override target
        force=-f
        ;;
    --use-patool)         # HELPOPT: force use patool as backend
        use_patool=1
        ;;
    --use-7z)             # HELPOPT: force use 7z as backend
        use_7z=1
        ;;
    --here|--no-subdir)   # HELPOPT: extract to current directory without creating a subdirectory
        here=1
        ;;
    -j|--flat|--junk-paths)  # HELPOPT: extract all files without directory structure
        flat=1
        ;;
    -C)                   # HELPOPT: extract to specified directory
        shift
        extract_dir="$1"
        ;;
    --directory|--extract-to|--destination|--outdir)
        shift
        extract_dir="$1"
        ;;
    --directory=*|--extract-to=*|--destination=*|--outdir=*)
        extract_dir="${1#*=}"
        ;;
    -a|-e|-x|-u|-l|-t|-b)
        # these are commands, not options
        break
        ;;
    -*)
        fatal "Unknown option '$1'"
        ;;
    *)
        break
        ;;
esac
shift
done

set_backend

cmd="$1"

lastarg="${@: -1}"

# Just printout help if run without args
if [ -z "$cmd" ] ; then
    print_version
    echo
    fatal "Run $ $progname --help for get help"
fi



# if the first arg is some archive, suggest extract
if get_archive_type "$cmd" 2>/dev/null >/dev/null ; then
    if is_target_format $lastarg ; then
        cmd=repack
    else
        cmd=extract
    fi
# erc dir (pack to zip by default)
elif [ -d "$cmd" ] && [ -z "$2" ] ; then
    cmd=pack
    target=$(basename "$1").zip
# erc dir zip:
elif test -r "$1" && is_target_format "$2" ; then
    cmd=pack
elif [ "$progname" = "unerc" ] ; then
    cmd=extract
else
    shift
fi


# TODO: Если программа-архиватор не установлена, предлагать установку с помощью epm

case $cmd in
    a|-a|create|pack|add)        # HELPCMD: create archive / add file(s) to archive
        # TODO: realize archive addition if already exist (and separate adding?)
        if [ -z "$target" ] && is_target_format $lastarg ; then
            [ $# = 2 ] || fatal "Need two args"
            target="$(build_target_name "$1" "$2")"
            # clear last arg
            set -- "${@:1:$(($#-1))}"
        fi
        [ -z "$target" ] && target="$1" && shift

        if [ -e "$target" ] ; then
            [ -n "$force" ] || fatal "Target $target already exists. Use -f to overwrite."
            docmd rm -f "$target"
        fi
        create_archive "$target" "$@"
        ;;
    e|x|-e|-x|u|-u|extract|unpack)          # HELPCMD: extract files from archive
        if [ -n "$extract_dir" ] ; then
            mkdir -p "$extract_dir" || fatal "Can't create directory '$extract_dir'"
            extract_dir="$(realpath -s "$extract_dir")"
        fi
        extract_archive "$@"
        ;;
# TODO: implement deletion
#    d|delete)             # HELPCMD: delete file(s) from archive
#        docmd patool delete "$@"
#        ;;
    l|-l|list)               # HELPCMD: list archive contents
        list_archive "$@"
        ;;
    t|-t|test|check)         # HELPCMD: test for archive integrity
        test_archive "$@"
        ;;
    type)                 # HELPCMD: print type of archive
        get_archive_type "$1" || fatal "Can't recognize $1 as archive"
        ;;
    basename)             # HELPCMD: print the predicted directory name for the archive
        get_archive_name "$1" || fatal "Can't recognize $1 as archive"
        ;;
    diff)                 # HELPCMD: compare two archive
        # check 2 arg
        docmd patool $verbose diff "$@"
        ;;
    b|-b|bench|benchmark)    # HELPCMD: do CPU benchmark
        #assure_cmd $HAVE_7Z
        # TODO: can be $HAVE_7Za?
        docmd $HAVE_7Z b
        ;;
    search|grep)               # HELPCMD: search in files from archive
        docmd patool $verbose search "$@"
        ;;
    repack|conv)          # HELPCMD: convert source archive to target
        # TODO: need repack remove source file?
        # TODO: check for 2 arg
        if ! is_target_format $lastarg ; then
            [ $# = 2 ] || fatal "Need two args"
            [ "$(realpath "$1")" = "$(realpath "$2")" ] && warning "Output file is the same as input" && exit
            [ -e "$2" ] && [ -n "$force" ] && docmd rm -f "$2"
            repack_archive "$1" "$2"
            exit
        fi

        # add support for target like zip:
        for i in "$@" ; do
            [ "$i" = "$lastarg" ] && continue
            target="$(build_target_name "$i" "$lastarg")"
            [ "$(realpath "$i")" = "$(realpath "$target")" ] && warning "Output file is the same as input" && exit
            [ -e "$target" ] && [ -n "$force" ] && docmd rm -f "$target"
            repack_archive "$i" "$target" || exit
        done

        ;;
    formats)              # HELPCMD: lists supported archive formats
        # TODO: print allowed with current programs separately
        if [ -n "$verbose" ] && have_patool ; then
            docmd patool formats "$@"
            echo "Also we supports:"
            ( list_subformats ; list_extraformats ) | sed -e "s|^|  |"
        else
            list_formats
        fi
        ;;
    *)
        # TODO: If we have archive in parameter, just unpack it
        fatal "Unknown command $1"
        ;;
esac
