#!/bin/sh

VERSION="1.2"

print_version()
{
    echo "${VERSION}"
    exit 0
}

print_help()
{
    echo "
Usage:
arithpkg [ -gt | -ge | -eq | -lt | -le ] [-N | -O | -C | -A] [-l] [-L] [-S 'char'] [-s 'char']
          -f <format> ARGOLD ARGNEW [ARGNEW2 ... ARGNEW_N]
arithpkg -d [-S 'char'] [-s 'char'] [-l] -f <format> ARG [ARG2 ... ARG_N]
input | arithpkg <options> ARGNEW
input | arithpkg -d <options>
arithpkg --help | --version

Compare versions of packages from sources ARGOLD and ARGNEW and print the difference.
ARGS may be:
  directories with packages;
  package file names directly.
  text lists (see -l, -L options)
Name and version to compare are extracted from package archives.

With -d option, print the versions of given packages from ARG to stdout.
ARG may be directory with packages or package file.

Possible comparison:
-gt
version from ARGNEW higher then the version of ARGOLD.
Default one.
-ge
version from ARGNEW higher or equal then the version of ARGOLD.
-eq
version from ARGNEW equals the version of ARGOLD.
-lt
version from ARGNEW lower then the version of ARGOLD.
-le
version from ARGNEW lower or equal then the version of ARGOLD.

The behaviour for unique names:
-N
print unique names for ARGNEW. Default one.
-O
print unique names for ARGOLD.
-C
do not print unique names.
-A
print all unique names, from ARGOLD and ARGNEW.

Arguments as lists:
-l
'old' packages are given by text file, not by packages themselves.
-L
'new' packages are given by text file, not by packages themselves.
Text files should contain package names and version, one at a line.

-f <format>
format of packages to compare.
Not used, when ARGS are text files with names and versions.

Supported formats:
  deb: debian packages, used by dpkg.
  txz: slackware packages, used by pkgtools and slackpkg.

-s 'char'
Use string 'char' as separator between package name and version in output.
Default string is symbol ' '.
-S 'char'
Use string 'char' as separator between package name and version in input lists, if they are used.
Default string is symbol ' '.
Mind if you change the input separator, but list contains spaces inside lines (not between lines),
the space will be used as separator nevertheless.
"
}

error_NOARGS='2'
error_BADARG='3'
error_NOFORMAT='4'
error_NOPACKTOOLS='5'
error_INNER='6'

error()
{
    if test -n "${ERROR_TO_FILE}" ; then
        echo "$1" >> ${ERRORLOG}
    else
        echo "$1" >& 2
    fi
}

generic_error()
{
    echo "$1" >& 2
}

msg()
{
    echo "$1" >& 2
}

fatal_error()
{
    echo "$1" >& 2
    exit 1
}

check_deb_tools()
{
    dpkg-deb --version > /dev/null 2>&1 || \
    (
        error "No dpkg-deb found with available PATH.
The tool is needed to parse deb packages."
        exit ${error_NOPACKTOOLS}
    )
}

set_format()
{

    # situations, when input data is entirely in text lists
    # and there is no need to parse packages
    if test -n "${MODE_DUMP}" -a -n "${MODE_LIST_OLD}" ; then
        return
    fi
    if test -n "${MODE_LIST_OLD}" -a -n "${MODE_LIST_NEW}" ; then
        return
    fi

    if test -z "${FORMAT_PKG}" ; then
        error "No package format is given. Assign it with -f option."
        exit ${error_NOFORMAT}
    fi

    case ${FORMAT_PKG} in
        deb)
            check_deb_tools
            FORMAT_PKG='deb'
            TEMPLATE_PKG="*.deb"
            FUNC_PARSE_PKG="packs_parse_deb"
        ;;
        txz)
            FORMAT_PKG='txz'
            TEMPLATE_PKG="*.txz"
            FUNC_PARSE_PKG="packs_parse_txz"
        ;;
        *)
            error "Unsupported format of package: ${FORMAT_PKG}
Supported formats:
deb
txz
"
            exit ${error_NOFORMAT}
        ;;
    esac
}

set_arith_action()
{
    case ${ARITH} in
        gt)
            FUNC_ARITH=arith_greater_then
        ;;
        ge)
            FUNC_ARITH=arith_greater_equal
        ;;
        eq)
            FUNC_ARITH=arith_equal
        ;;
        lt)
            FUNC_ARITH=arith_lower_then
        ;;
        le)
            FUNC_ARITH=arith_lower_equal
        ;;
        *)
            error "Unsupported arithmetic action: ${ARITH}"
            exit ${error_INNER}
        ;;
    esac
}

stream_read()
{
    if test ! -t 0 ; then
        while read line_list <&0 ; do
            case "${line_list}" in
                # commented and empty strings
                '#'*|'')
                    continue
                ;;
            esac

            # version of package is defined with high priority as second field in the list after the name
            version=
            second_field="${line_list#* }"
            if test "${second_field}" != "${line_list}" ; then
                # if there are more space-delimited fields after second one, remove them
                version="${second_field%% *}"
                name="${line_list%% *}"

            # if there are no space-separated fields, check non-default IN_SEPARATOR
            # Such SEPARATOR as '-' can not simply delimit name and version,
            # cause package name and version often contain '-' as well
            elif test -n "${IN_SEPARATOR}" -a "${IN_SEPARATOR}" != '-' ; then
                name="${line_list%%${IN_SEPARATOR}*}"
                version="${line_list#*${IN_SEPARATOR}}"
            # if there is no sane separator, define version classically:
            # first word, delimited with '-', beginning with digit, is the beginning of the version
            else
                name="${line_list%%-[0-9][^-]*}"
                name="${name%%-[0-9]}"
                version="${line_list##${name}-}"
            fi
            if test -z "${name}" ; then
                error "Incorrect line in list:
${line_list}
No package name defined."
                continue
            fi
            version_string=
            if test -n "${version}" ; then
                version_string="${OUT_SEPARATOR}${version}"
            fi

            printf "%s\n" "${name}${version_string}"
        done
    fi
}

packs_parse_txz()
{
    if test ! -t 0 ; then
        while read parse_pack <&0 ; do
            # remove leading path
            template_name="${parse_pack##*/}"
            # remove file extension
            template_name="${template_name%.txz}"
            # get inner version
            inner_version="${template_name##*-}"
            # remove inner version
            template_name="${template_name%-*}"
            # remove arch
            template_name="${template_name%-*}"
            # detect pair name\version
            name="${template_name%-*}"
            version="${template_name##*-}"

            if test -z "${name}" -o -z "${version}" ; then
                error "Incorrect name or version in file ${parse_pack}"
                continue
            fi

            # do not forget inner version
            printf "%s\n" "${name}${OUT_SEPARATOR}${version}-${inner_version}"
        done
    fi
}

packs_parse_deb()
{
    if test ! -t 0 ; then
        while read parse_pack <&0 ; do
            name="$( dpkg-deb -f ${parse_pack} package )"
            version="$( dpkg-deb -f ${parse_pack} version )"
            if test -z "${name}" -o -z "${version}" ; then
                error "Incorrect name or version in file ${parse_pack}"
                continue
            fi

            printf "%s\n" "${name}${OUT_SEPARATOR}${version}"
        done
    fi
}

packs_read()
{
    if test ! -t 0 ; then
        while read line_input_pack <&0 ; do
            if test -f "${line_input_pack}" ; then
                printf "%s\n" "${line_input_pack}"
            elif test -d "${line_input_pack}" ; then
                find "${line_input_pack}" -type f -name "${TEMPLATE_PKG}"
            fi
        done
    fi
}

main_dump()
{
    set_format

    if test -n "${MODE_LIST_OLD}" ; then
        if test -t 0 ; then
            # this works with several files inside ARGDUMP as well
            cat ${ARGDUMP} | stream_read
        else
            stream_read <& 0
        fi
    else
        if test -t 0 ; then
            printf "%s\n" "${ARGDUMP}" | packs_read | ${FUNC_PARSE_PKG}
        else
            packs_read <& 0 | ${FUNC_PARSE_PKG}
        fi
    fi
}

# very similar to main_dump besides ARG variable, keep it near main_dump
main_input()
{
    # we need to work only with space-delimited fields
    # for output here for inner needs
    if test -n "${OUT_SEPARATOR}" ; then
        backup_out="${OUT_SEPARATOR}"
        OUT_SEPARATOR=" "
    fi

    if test -n "${MODE_LIST_OLD}" ; then
        if test -t 0 ; then
            # this works with several files inside ARGOLD as well
            cat ${ARGOLD} | stream_read
        else
            stream_read <& 0
        fi
    else
        if test -t 0 ; then
            printf "%s\n" "${ARGOLD}" | packs_read | ${FUNC_PARSE_PKG}
        else
            packs_read <& 0 | ${FUNC_PARSE_PKG}
        fi
    fi

    # return to user setting
    if test -n "${backup_out}" ; then
        OUT_SEPARATOR="${backup_out}"
    fi
}

translate_dict_key()
{
    word="$1"

    normalised_word=
    while test -n "${word}" ; do

        remove="${word#?}"
        letter="${word%${remove}}"
        word="${remove}"

        case ${letter} in
            [[:alpha:]_])
                normalised_word="${normalised_word}${letter}"
            ;;
            -)
                normalised_word="${normalised_word}_"
            ;;
            0)
                normalised_word="${normalised_word}__zero__"
            ;;
            1)
                normalised_word="${normalised_word}__one__"
            ;;
            2)
                normalised_word="${normalised_word}__two__"
            ;;
            3)
                normalised_word="${normalised_word}__three__"
            ;;
            4)
                normalised_word="${normalised_word}__four__"
            ;;
            5)
                normalised_word="${normalised_word}__five__"
            ;;
            6)
                normalised_word="${normalised_word}__six__"
            ;;
            7)
                normalised_word="${normalised_word}__seven__"
            ;;
            8)
                normalised_word="${normalised_word}__eight__"
            ;;
            9)
                normalised_word="${normalised_word}__nine__"
            ;;
            .)
                normalised_word="${normalised_word}__dot__"
            ;;
            +)
                normalised_word="${normalised_word}__plus__"
            ;;
            *)
                #delete inappropriate symbol
            ;;
        esac
    done

    printf "%s\n" "${PREFDICT}${normalised_word}"
}

translate_dict_line()
{
    if test -t 0 ; then
        return
    fi
    while read word ; do
        normalised_word=
        # need to save non-translated name
        original_name="${word%% *}"
        while test -n "${word}" ; do

            remove="${word#?}"
            letter="${word%${remove}}"
            word="${remove}"

            # do nothing after separator ' '
            # non-key content goes further, which does not need translation
            if test "${letter}" = ' ' ; then
                # return separator back
                word="${letter}${word}"
                break
            fi

            case ${letter} in
                [[:alpha:]_])
                    normalised_word="${normalised_word}${letter}"
                ;;
                -)
                    normalised_word="${normalised_word}_"
                ;;
                0)
                    normalised_word="${normalised_word}__zero__"
                ;;
                1)
                    normalised_word="${normalised_word}__one__"
                ;;
                2)
                    normalised_word="${normalised_word}__two__"
                ;;
                3)
                    normalised_word="${normalised_word}__three__"
                ;;
                4)
                    normalised_word="${normalised_word}__four__"
                ;;
                5)
                    normalised_word="${normalised_word}__five__"
                ;;
                6)
                    normalised_word="${normalised_word}__six__"
                ;;
                7)
                    normalised_word="${normalised_word}__seven__"
                ;;
                8)
                    normalised_word="${normalised_word}__eight__"
                ;;
                9)
                    normalised_word="${normalised_word}__nine__"
                ;;
                .)
                    normalised_word="${normalised_word}__dot__"
                ;;
                +)
                    normalised_word="${normalised_word}__plus__"
                ;;
                *)
                    #delete inappropriate symbol
                ;;
            esac

        done

        remaining_line="${word}"
        printf "%s" "${PREFDICT}${normalised_word}${remaining_line}"
        tail_field=
        if test -n "${NEED_DICT_NAMES}" ; then
            # pass original_name additionally as last field in string
            tail_field=" ${original_name}"
        fi
        printf "%s\n" "${tail_field}"

    done <& 0 # outer input cycle

}

create_dict()
{
    translate_dict_line <& 0 | while read line_dict ; do

        name="${line_dict%% *}"
        value="${line_dict#* }"
        value="${value%% *}"

        printf "%s\n" "${name}='${value}'"

        if test -n "${NEED_DICT_NAMES}" ; then
            # field with original name is added by translate_dict_line
            original_name="${line_dict##* }"

            # create two parallel dictionaries, by the scheme:
            # first one: key=value, second one: modified_key=original_name
            # this is needed to access original name of input data by inner key

            printf "%s\n" "${PREFDICT_NAME}${name}='${original_name}'"
        fi
    done
}

get_from_dict()
{
    arg="$1"

    eval "echo \${$( translate_dict_key "${arg}" )}"
}

get_dict_value_by_key()
{
    key="$1"

    eval "echo \${"${key}"}"
}

get_dict_name_by_key()
{
    arg="$1"

    # trickery with transforming the key for parallel dict
    key="${PREFDICT_NAME}${arg}"
    eval "echo \${"${key}"}"
}

get_field_text()
{
    string="$1"
    numeric="$2"

    text=
    while test -n "${string}" ; do
        if test -n "${numeric}" ; then
            remove="${string#[[:digit:]]}"
        else
            remove="${string#[^[:digit:]]}"
        fi
        char="${string%${remove}}"
        if test -n "${char}" ; then
            text="${text}${char}"
            string="${string#${char}}"
        else
            break
        fi
    done

    if test -n "${text}" ; then
        printf "%s\n" "${text}"
    fi
}

compare_versions()
{
    old="$1"
    new="$2"

    if test "${old}" = "${new}" ; then
        status='eq'
        printf "%s\n" "${status}"
        return 0
    fi

    status='gt'
    while test -n "${old}" -a -n "${new}" ; do

        # process numeric part of version
        digits_old="$(get_field_text "${old}" 'num')"
        digits_new="$(get_field_text "${new}" 'num')"

        if test -n "${digits_old}" -a -z "${digits_new}" ; then
            status='lt'
            break
        fi
        if test -z "${digits_old}" -a -n "${digits_new}" ; then
            status='gt'
            break
        fi
        if test -n "${digits_old}" -a -n "${digits_new}" ; then

            if test "${digits_new}" -ne "${digits_old}" ; then
                if test "${digits_new}" -gt "${digits_old}" ; then
                    status='gt'
                else
                    status='lt'
                fi
                break
            fi

            old="${old#${digits_old}}"
            new="${new#${digits_new}}"
            # now numeric part has been removed
        fi
        # process textual part of version
        text_old="$(get_field_text "${old}")"
        text_new="$(get_field_text "${new}")"

        if test -n "${text_old}" -a -z "${text_new}" ; then
            status='lt'
            break
        fi
        if test -z "${text_old}" -a -n "${text_new}" ; then
            status='gt'
            break
        fi
        if test -n "${text_old}" -a -n "${text_new}" ; then

            if test "${text_new}" '!=' "${text_old}" ; then
                if test "${text_new}" '>' "${text_old}" ; then
                    status='gt'
                else
                    status='lt'
                fi
                break
            fi

            old="${old#${text_old}}"
            new="${new#${text_new}}"
            # now text part has been removed
        fi

    done

    printf "%s\n" "${status}"
}

arith_greater_then()
{
    status="$(compare_versions "$1" "$2" )"
    if test "${status}" = "gt" ; then
        return 0
    fi
    return 1
}

arith_greater_equal()
{
    status="$(compare_versions "$1" "$2" )"
    if test "${status}" = "gt" -o "${status}" = "eq" ; then
        return 0
    fi
    return 1
}

arith_equal()
{
    status="$(compare_versions "$1" "$2" )"
    if test "${status}" = "eq" ; then
        return 0
    fi
    return 1
}

arith_lower_then()
{
    status="$(compare_versions "$1" "$2" )"
    if test "${status}" = "lt" ; then
        return 0
    fi
    return 1
}

arith_lower_equal()
{
    status="$(compare_versions "$1" "$2" )"
    if test "${status}" = "lt" -o "${status}" = "eq" ; then
        return 0
    fi
    return 1
}

filter_prefixed_vars()
{
    if test -t 0 ; then
        return
    fi
    prefix="$1"
    while read input ; do
        case "${input}" in
            "${prefix}"*)
                remove="${input#${prefix}*=}"
                name="${input%=${remove}}"
                printf "%s\n" "${name}"
            ;;
        esac
    done <& 0
}

merge_by_version()
{
    if test -t 0 ; then
        return
    fi
    while read line_dict ; do
        name="${line_dict%% *}"
        value="${line_dict#* }"
        value="${value%% *}"

        # not using get_from_dict() to store the key
        key="$( translate_dict_key "${name}" )"
        old_value="$(get_dict_value_by_key "${key}" )"

        if test -z "${old_value}" ; then
            # that is package without version, not unique
            if test -z "${value}" ; then
                printf "%s\n" "${name}${OUT_SEPARATOR}NONE${OUT_SEPARATOR}NONE"
                continue
            fi

            case ${UNIQ_MODE} in
                new|all)
                    printf "%s\n" "${name}${OUT_SEPARATOR}NONE${OUT_SEPARATOR}${value}"
                ;;
            esac
            continue
        fi
        if test -z "${value}" ; then
            case ${UNIQ_MODE} in
                old|all)
                    printf "%s\n" "${name}${OUT_SEPARATOR}${old_value}${OUT_SEPARATOR}NONE"
                ;;
            esac
            continue
        fi

        if test -n "${NEED_DICT_NAMES}" ; then
            # this is to detect non-traced entries from old set in dictionary
            # unset everything here, remaining will be unique to old set
            eval "unset ${key}"
        fi

        if ${FUNC_ARITH} "${old_value}" "${value}" ; then
            printf "%s\n" "${name}${OUT_SEPARATOR}${old_value}${OUT_SEPARATOR}${value}"
        fi

    done <& 0

    case ${UNIQ_MODE} in
        old|all)
            set | filter_prefixed_vars "${PREFDICT}" | while read old_key ; do
                name="$(get_dict_name_by_key "${old_key}" )"
                old_value="$(get_dict_value_by_key "${old_key}" )"
                printf "%s\n" "${name}${OUT_SEPARATOR}${old_value}${OUT_SEPARATOR}NONE"
            done
        ;;
    esac
}

main()
{
    set_format
    set_arith_action

    # create and save dictionary from 'old' set of packages
    eval "$(main_input | create_dict)"

    # read 'new' set of packages and compare with 'old'
    (
    if test -n "${MODE_LIST_NEW}" ; then
        # this works with several files inside ARGNEW as well
        cat ${ARGNEW} | stream_read
    else
        printf "%s\n" "${ARGNEW}" | packs_read | ${FUNC_PARSE_PKG}
    fi
    ) | merge_by_version
}

ARITH='gt'
UNIQ_MODE='new'
FORMAT_PKG=
TEMPLATE_PKG=
IN_SEPARATOR=' '
OUT_SEPARATOR=' '
MODE_DUMP=
MODE_LIST_OLD=
MODE_LIST_NEW=
ARGOLD=
ARGNEW=
ARGDUMP=
PREFDICT='__dict_arithpkg_'
PREFDICT_NAME='_nm_'
FUNC_PARSE_PKG=
FUNC_ARITH=
NEED_DICT_NAMES=

in_arg_cycle=1

while test -n "$in_arg_cycle" ; do
    arg="$1"
    in_arg_cycle=

    checked_arg="${arg#-}" || true
    if [ "$checked_arg" != "${arg}" ] ; then
        in_arg_cycle=1
        shift

        case ${checked_arg} in
            ge)
                ARITH='ge'
            ;;
            gt)
                ARITH='gt'
            ;;
            eq)
                ARITH='eq'
            ;;
            le)
                ARITH='le'
            ;;
            lt)
                ARITH='lt'
            ;;
            N)
                UNIQ_MODE='new'
            ;;
            O)
                UNIQ_MODE='old'
                NEED_DICT_NAMES=1
            ;;
            C)
                UNIQ_MODE='none'
            ;;
            A)
                UNIQ_MODE='all'
                NEED_DICT_NAMES=1
            ;;
            f)
                FORMAT_PKG="$1"
                shift || fatal_error "Argument for -f is not given."
            ;;
            l)
                MODE_LIST_OLD='1'
            ;;
            L)
                MODE_LIST_NEW='1'
            ;;
            d)
                MODE_DUMP='1'
            ;;
            s)
                OUT_SEPARATOR="$1"
                shift || fatal_error "Argument for -s is not given."
            ;;
            S)
                IN_SEPARATOR="$1"
                shift || fatal_error "Argument for -S is not given."
            ;;
            -version)
                print_version
            ;;
            -help)
                print_help
                exit 0
            ;;
            -)
                in_arg_cycle=
            ;;
            *)
                fatal_error "Unknown option ${arg}"
            ;;
        esac
    fi
done

if test -n "${MODE_DUMP}" ; then

    # non-piped input, arguments in cmd
    if test -t 0 ; then
        while test -n "$1" ; do
            if test -f "${1}" -o -d "${1}" ; then
                ARGDUMP="${ARGDUMP}
${1}"
            else
                error "Argument must be file or directory with packages."
                exit ${error_BADARG}
            fi
            shift
        done
        # delete empty string(s) at the beginning
        ARGDUMP="${ARGDUMP##
}"
        if test -z "${ARGDUMP}" ; then
            error "No arguments with packages are provided."
            exit ${error_NOARGS}
        fi
    fi # test -t 0

    main_dump
    exit
fi

if test -t 0 ; then
    ARGOLD="$1"
    if test -z "${ARGOLD}" ; then
        error "No arguments with old packages are provided."
        exit ${error_NOARGS}
    fi
    if ! test -f "${ARGOLD}" -o -d "${ARGOLD}" ; then
        error "Argument must be file or directory with packages."
        exit ${error_BADARG}
    fi
    shift
fi

while test -n "$1" ; do
    if test -f "${1}" -o -d "${1}" ; then
        ARGNEW="${ARGNEW}
${1}"
    else
        error "Argument must be file or directory with packages."
        exit ${error_BADARG}
    fi
    shift
done
# delete empty string(s) at the beginning
ARGNEW="${ARGNEW##
}"
if test -z "${ARGNEW}" ; then
    error "No arguments with new packages are provided."
    exit ${error_NOARGS}
fi

main

# ---------------------------------------------------------

# Copyright (c) 2020 Random Crew.
#
# Available under ISC License.

# ---------------------------------------------------------

