#!/bin/sh /etc/rc.common
# shellcheck disable=SC3043,SC1091,SC2155,SC3020,SC3010,SC2016,SC3060,SC3003,SC3015,SC3044

START=99
STOP=99

USE_PROCD=1

VERSION="dev"
UPD_CHANNEL="release"

EXTRA_COMMANDS="check_version update auto_setup auto_setup_noninteractive validate_custom_rules health_check"
EXTRA_HELP="
        check_version              Check for updates
        update                     Update qosmate
        auto_setup                 Automatically configure qosmate
        auto_setup_noninteractive  Automatically configure qosmate with no interaction
        validate_custom_rules      Validate custom rules
        health_check               Check if QoSmate is properly configured and running"

REQUIRED_PACKAGES="kmod-sched ip-full kmod-veth tc-full kmod-netem kmod-sched-ctinfo kmod-ifb kmod-sched-cake kmod-sched-red luci-lib-jsonc lua jq coreutils-sleep"

### Utility vars
_NL_='
'
DEFAULT_IFS=" 	${_NL_}"
IFS="$DEFAULT_IFS"


### repo-related vars

# GH repo author (can be overriden for testing)
: "${QOSMATE_REPO_AUTHOR:=hudra0}"

MAIN_BRANCH_BACKEND=main
MAIN_BRANCH_FRONTEND=main

# GH API URLs (can be overridden)
: "${QOSMATE_GH_API_URL:="https://api.github.com/repos/${QOSMATE_REPO_AUTHOR}"}"
: "${QOSMATE_GH_API_URL_BACKEND:="${QOSMATE_GH_API_URL}/qosmate"}"
: "${QOSMATE_GH_API_URL_FRONTEND:="${QOSMATE_GH_API_URL}/luci-app-qosmate"}"

# GH raw backend URL (can be overridden)
: "${QOSMATE_GH_RAW_URL_BACKEND_MAIN:="https://raw.githubusercontent.com/${QOSMATE_REPO_AUTHOR}/qosmate/${MAIN_BRANCH_BACKEND}"}"


### Local paths

QOSMATE_D=/etc/qosmate.d
QOSMATE_CFG_FILE=/etc/config/qosmate
TMP_CFG_DIR=/tmp/qosmate_tmp
TMP_CFG_FILE=$TMP_CFG_DIR/qosmate
QOSMATE_DEFAULTS_FILE=${QOSMATE_D}/qosmate-defaults
QOSMATE_CUSTOM_RULES_FILE=${QOSMATE_D}/custom_rules.nft
QOSMATE_INLINE_RULES_FILE=${QOSMATE_D}/inline_dscptag.nft
QOSMATE_RUN_DIR=/tmp/qosmate

# Local path of JS files
QOSMATE_UPD_DIR=/var/run/qosmate-update

### Components
QOSMATE_COMPONENTS="BACKEND FRONTEND"
QOSMATE_FILES_REG_PATH_BACKEND=${QOSMATE_D}/backend_reg.md5
QOSMATE_FILES_REG_PATH_FRONTEND=${QOSMATE_D}/frontend_reg.md5

### Backend files
QOSMATE_SERVICE_PATH=/etc/init.d/qosmate
QOSMATE_MAIN_SCRIPT=/etc/qosmate.sh
QOSMATE_HOTPLUG_SCRIPT=/etc/hotplug.d/iface/13-qosmateHotplug

# stores the version and update channel
QOSMATE_VERSION_FILE_BACKEND=$QOSMATE_SERVICE_PATH

QOSMATE_FILE_TYPES_BACKEND="GEN EXTRA" # change the value if adding more types
QOSMATE_AUTORATE_SCRIPT=/etc/qosmate-autorate.sh
QOSMATE_AUTORATE_TC_SCRIPT=/etc/qosmate-autorate-tc.sh
QOSMATE_AUTORATE_SERVICE=/etc/init.d/qosmate-autorate

QOSMATE_GEN_FILES_BACKEND="
    $QOSMATE_SERVICE_PATH
    $QOSMATE_MAIN_SCRIPT
    $QOSMATE_DEFAULTS_FILE
    $QOSMATE_AUTORATE_SCRIPT
    $QOSMATE_AUTORATE_TC_SCRIPT
    $QOSMATE_AUTORATE_SERVICE
    /usr/lib/tc/experimental.dist
    /usr/lib/tc/normal.dist
    /usr/lib/tc/normmix20-64.dist
    /usr/lib/tc/pareto.dist
    /usr/lib/tc/paretonormal.dist"
QOSMATE_EXEC_FILES_BACKEND="
    $QOSMATE_SERVICE_PATH
    $QOSMATE_MAIN_SCRIPT
    $QOSMATE_AUTORATE_SCRIPT
    $QOSMATE_AUTORATE_SERVICE"
QOSMATE_EXTRA_FILES_BACKEND="" # might be useful in the future?


### Frontend files
### !!! When adding or removing frontend (or other) files which need path fixups,
###       remember to update appropriate fixup files

QOSMATE_FRONTEND_DIR_JS=/www/luci-static/resources/view/qosmate

# stores the version and update channel
QOSMATE_VERSION_FILE_FRONTEND="${QOSMATE_FRONTEND_DIR_JS}/settings.js"

QOSMATE_FILE_TYPES_FRONTEND="GEN EXTRA JS" # change the value if adding more types
QOSMATE_GEN_FILES_FRONTEND="
    /usr/share/luci/menu.d/luci-app-qosmate.json
    /usr/share/rpcd/acl.d/luci-app-qosmate.json
    /usr/libexec/rpcd/luci.qosmate
    /usr/libexec/rpcd/luci.qosmate_stats"
QOSMATE_EXTRA_FILES_FRONTEND="" # might be useful in the future?

QOSMATE_JS_FILENAMES="
    settings.js
    hfsc.js
    cake.js
    advanced.js
    rules.js
    ratelimits.js
    connections.js
    custom_rules.js
    ipsets.js
    statistics.js"
# generate js file list with paths
QOSMATE_JS_FILES_FRONTEND=
for js_file in ${QOSMATE_JS_FILENAMES}; do
    QOSMATE_JS_FILES_FRONTEND="${QOSMATE_JS_FILES_FRONTEND}${QOSMATE_FRONTEND_DIR_JS}/${js_file}${_NL_}"
done

QOSMATE_EXEC_FILES_FRONTEND="
    /usr/libexec/rpcd/luci.qosmate
    /usr/libexec/rpcd/luci.qosmate_stats"


# Silence shellcheck unused vars warnings
: "$START" "$STOP" "$USE_PROCD" "$VERSION" "$UPD_CHANNEL" "$EXTRA_COMMANDS" "$EXTRA_HELP" "$REQUIRED_PACKAGES" \
    "$MAIN_BRANCH_FRONTEND" "$QOSMATE_FILES_REG_PATH_BACKEND" "$QOSMATE_FILES_REG_PATH_FRONTEND" \
    "$QOSMATE_VERSION_FILE_BACKEND" "$QOSMATE_FILE_TYPES_BACKEND" "$QOSMATE_GEN_FILES_BACKEND" \
    "$QOSMATE_EXEC_FILES_BACKEND" "$QOSMATE_EXTRA_FILES_BACKEND" "$QOSMATE_FILE_TYPES_FRONTEND" \
    "$QOSMATE_GEN_FILES_FRONTEND" "$QOSMATE_EXTRA_FILES_FRONTEND" "$QOSMATE_EXEC_FILES_FRONTEND" \
    "$QOSMATE_AUTORATE_SCRIPT" "$QOSMATE_AUTORATE_TC_SCRIPT" "$QOSMATE_AUTORATE_SERVICE" \
    "${global_enabled:=}" "${ROOT_QDISC:=}" "${gameqdisc:=}"


### Utility functions

uci_tmp() {
    uci -c "$TMP_CFG_DIR" "$@"
}

commit_tmp_config() {
    uci_tmp commit qosmate &&
    cp "$TMP_CFG_FILE" "$QOSMATE_CFG_FILE"
    local rv=$?
    rm -rf "$TMP_CFG_DIR"
    [ $rv = 0 ] || error_out "Failed to save the new config."
    return $rv
}

# 1 - string
# 2 - path to file
write_str_to_file() {
    printf '%s\n' "$1" > "$2" || { error_out "Failed to write to file '$2'."; return 1; }
    :
}

# 0 - (optional) '-p'
# 1 - path
try_mkdir() {
    local IFS="$DEFAULT_IFS" p=
    [ "$1" = '-p' ] && { p='-p'; shift; }
    [ -d "$1" ] && return 0
    mkdir ${p} "$1" || { error_out "Failed to create directory '$1'."; return 1; }
    :
}

check_util() { command -v "$1" 1>/dev/null; }

error_out() { log_msg -err "${@}"; }

# prints each argument to a separate line
print_msg() {
    local _arg
    for _arg in "$@"
    do
        case "${_arg}" in
            '') printf '\n' ;; # print out empty lines
            *) printf '%s\n' "${_arg}"
        esac
    done
    :
}

# logs each argument separately and prints to a separate line
# optional arguments: '-err', '-warn' to set logged error level
log_msg() {
    local msgs_prefix='' _arg err_l=info msgs_dest

    local IFS="$DEFAULT_IFS"
    for _arg in "$@"
    do
        case "${_arg}" in
            "-err") err_l=err msgs_prefix="Error: " ;;
            "-warn") err_l=warn msgs_prefix="Warning: " ;;
            '') printf '\n' ;; # print out empty lines
            *)
                case "$err_l" in
                    err|warn) msgs_dest="/dev/stderr" ;;
                    *) msgs_dest="/dev/stdout"
                esac
                printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest"
                logger -t qosmate -p user."$err_l" "${msgs_prefix}${_arg}"
                msgs_prefix=''
        esac
    done
    :
}

# check if var names are safe to use with eval
are_var_names_safe() {
    local var_name
    for var_name in "$@"; do
        case "$var_name" in *[!a-zA-Z_]*) error_out "Invalid var name '$var_name'."; return 1; esac
    done
    :
}

### Update-related functions

# 1 - component: <BACKEND|FRONTEND>
# return codes:
# 0 - OK
# 1 - general error
# 2 - missing files
# 3 - missing reg file
# 4 - non-matching md5sums
check_files_integrity() {
    local files_missing='' reg_missing='' reg_file file files file_types component="$1"

    eval "file_types=\"\${QOSMATE_FILE_TYPES_${component}}\"
        reg_file=\"\${QOSMATE_FILES_REG_PATH_${component}}\""

    [ -f "$reg_file" ] || reg_missing=1

    files="$(print_file_list "$component" ALL)" && [ -n "$files" ] ||
        { error_out "Failed to get file list for component '$component'."; return 1; }
    
    local IFS="$_NL_"
    for file in $files; do
        [ -z "$file" ] || [ -f "$file" ] && continue
        error_out "Missing file: '$file'."
        files_missing=1
    done

    IFS="$DEFAULT_IFS"
    [ -z "$files_missing" ] || return 2
    [ -z "$reg_missing" ] || { error_out "$reg_file is not found. Can not check files integrity."; return 3; }

    md5sum -c "$reg_file" &>/dev/null || return 4
    :
}

# 1 - component: BACKEND|FRONTEND
# 2 - types: 'ALL' (doesn't print executable files) or any combination (space-separated) of 'GEN', 'EXTRA', 'JS', 'EXEC'
print_file_list() {
    local me=print_file_list file_type files='' \
        component="$1" file_types="$2"

    [ -n "$1" ] && [ -n "$2" ] || { error_out "$me: missing args."; return 1; }

    case "$component" in
        BACKEND|FRONTEND) ;;
        *) error_out "$me: invalid component '$component'."; return 1
    esac

    if [ "$file_types" = ALL ]; then
        eval "file_types=\"\${QOSMATE_FILE_TYPES_${component}}\""
    fi

    for file_type in ${file_types}; do
        case "$file_type" in
            GEN|EXTRA|JS|EXEC)
                eval "files=\"${files}\${QOSMATE_${file_type}_FILES_${component}}${_NL_}\"" ;;
            *) error_out "$me: invalid type '$file_type'"; return 1
        esac
    done

    # remove extra newlines, leading and trailing whitespaces and tabs
    printf '%s\n' "$files" | sed "s/^\s*//;s/\s*$//;/^$/d"
    :
}

# When updating, old version of the script should call post_update_[X] functions from the new version
#   post_update_1 should be called via '/bin/sh <qosmate_init_preinst_path> post_update_1' *before* new version is installed
#   post_update_2 should be called via '/bin/sh "$QOSMATE_SERVICE_PATH" post_update_2' *after* new version is installed
# This allows flexibility when adding new features etc.
post_update_1() {
    :
    # <do something...>
    return $?
}

post_update_2() {
    # <do something...>
    migrate_config
    return $?
}

# Fetches qosmate distribution for specified component
# 1 - component (BACKEND|FRONTEND)
# 2 - tarball url
fetch_qosmate_component() {
    [ -n "$1" ] && [ -n "$2" ] || { error_out "fetch_qosmate_component: missing arguments."; return 1; }

    local component="$1" fetch_tarball_url="$2"
    local fetch_rv extract_dir upd_dir="${QOSMATE_UPD_DIR}/${component}"
    local tarball="${upd_dir}/remote_qosmate.tar.gz" ucl_err_file="${upd_dir}/ucl_err"

    case "$component" in
        BACKEND) repo_name=qosmate ;;
        FRONTEND) repo_name=luci-app-qosmate ;;
        *) error_out "$me: invalid component '$component'."; return 1
    esac

    rm -f "$ucl_err_file" "${tarball}"
    rm -rf "${upd_dir}/${QOSMATE_REPO_AUTHOR}-${repo_name}-"*
    try_mkdir -p "$upd_dir" || return 1

    uclient-fetch "$fetch_tarball_url" -O "${tarball}" 2> "$ucl_err_file" &&
    grep -q "Download completed" "$ucl_err_file" &&
    tar -C "${upd_dir}" -xzf "${tarball}" &&
    extract_dir="$(find "${upd_dir}/" -type d -name "${QOSMATE_REPO_AUTHOR}-${repo_name}-*")" &&
        [ -n "$extract_dir" ] && [ "$extract_dir" != "/" ]
    fetch_rv=${?}
    rm -f "${tarball}"

    [ "$fetch_rv" != 0 ] && [ -s "$ucl_err_file" ] &&
        log_msg "uclient-fetch output: ${_NL_}$(cat "$ucl_err_file")."
    rm -f "$ucl_err_file"

    [ "$fetch_rv" = 0 ] && {
        mv "${extract_dir:-?}"/* "${upd_dir:-?}/" ||
            { rm -rf "${extract_dir:-?}"; error_out "Failed to move files to dist dir."; return 1; }
    }
    rm -rf "${extract_dir:-?}"

    return $fetch_rv
}

# Get GitHub ref and tarball url for specified component, update channel, branch and version
# 1 - component (BACKEND|FRONTEND)
# 2 - update channel: release|snapshot|branch=<github_branch>|commit=<commit_hash>
# 3 - version (optional): [qosmate_version|commit_hash]
# Output via variables:
#   $4 - github ref (version/commit hash), $5 - tarball url, $6 - version type ('version' or 'commit')
get_gh_ref_data() {
    set_res_vars() {
        if [ "$gh_channel" = release ]; then
            version="${gh_ref#v}"
        else
            version="$gh_ref"
        fi
        eval "$4"='$version' "$5"='${gh_url_api}/tarball/${gh_ref}' "$6"='$gh_ver_type' \
            "${component}_prev_ref"='$gh_ref' "${component}_prev_ver_type"='$gh_ver_type' \
            "${component}_prev_upd_channel"='$gh_channel' "${component}_prev_version"='$version'
    }

    get_and_process_ref()
    {
        local branch \
            channel="$1" ptrn="$2" branches="$3" main_branch="$4" err_file="$5"
        case "${channel}" in
            release)
                uclient-fetch "${gh_url_api}/releases" -O- 2> "$err_file" | {
                    jsonfilter -e '@[@.prerelease=false]' |
                    jsonfilter -a -e "@[@.target_commitish=\"${main_branch}\"].tag_name"
                    cat 1>/dev/null
                } ;;
            snapshot|branch=*|commit=*)
                for branch in ${branches}
                do
                    uclient-fetch "${gh_url_api}/commits?sha=${branch}" -O- 2> "$err_file" | {
                        jsonfilter -e '@[@.commit]["url"]' |
                        sed 's/.*\///' # only leave the commit hash
                        cat 1>/dev/null
                    }
                done
        esac |
        if [ -n "${ptrn}" ]
        then
            grep "${ptrn}"
        else
            head -n1 # get latest version or commit
            cat 1>/dev/null
        fi
    }

    local branches='' main_branch grep_ptrn='' \
        gh_ref='' gh_ver_type='' gh_url_api ref_fetch_tmp_dir="/tmp/qosmate-gh" ref_fetch_rv=0 \
        prev_ref prev_ver_type prev_upd_channel prev_version \
        component="$1" gh_channel="$2" version="$3"
    
    [ "$gh_channel" = release ] && version="${version#v}"

    local ref_ucl_err_file="${ref_fetch_tmp_dir}/ucl_err"

    are_var_names_safe "$4" "$5" "$6" || return 1
    eval "$4='' $5='' $6='' gh_url_api=\"\${QOSMATE_GH_API_URL_${component}}\""

    eval "prev_ref=\"\${${component}_prev_ref}\"
        prev_ver_type=\"\${${component}_prev_ver_type}\"
        prev_upd_channel=\"\${${component}_prev_upd_channel}\"
        prev_version=\"\${${component}_prev_version}\""

    # if previously stored data exists, use it without API query or cache check
    if [ -n "$prev_ref" ] && [ -n "$prev_ver_type" ] && \
        [ "$prev_upd_channel" = "$gh_channel" ] && [ "$version" = "$prev_version" ]; then
            gh_ref="$prev_ref" gh_ver_type="$prev_ver_type"
    else
        # if commit hash is specified and it's 40-char long, use it directly without API query or cache check
        case "$gh_channel" in
            snapshot|branch=*|commit=*) [ "${#version}" = 40 ] && gh_ref="$version"
        esac

        if [ -z "$gh_ref" ]; then
            # ref cache
            local cache_file cache_filename="${component}_${version}_${gh_channel}" ref_cache_dir="/tmp/qosmate_cache" cache_ttl
            case "$gh_channel" in
                commit=*) cache_ttl=2880 ;; # 48 hours
                *) cache_ttl=10 # 10 minutes
            esac

            # clean up old cache
            find "${ref_cache_dir:-?}" -maxdepth 1 -type f -mmin +"${cache_ttl}" -exec rm -f {} \; 2>/dev/null

            # check if the query is cached
            cache_file="$(find "${ref_cache_dir:-?}" -maxdepth 1 -type f -name "${cache_filename}" -print 2>/dev/null)"
            case "$cache_file" in
                '') ;; # found nothing
                *[^"$_NL_"]*"${_NL_}"*[^"${_NL_}"]*)
                    # found multiple files - delete them
                    local file IFS="$_NL_"
                    for file in $cache_file; do
                        [ -n "$file" ] || continue
                        rm -f "$file"
                    done
                    IFS="$DEFAULT_IFS" ;;
                *)
                    # found cached query
                    if [ -z "$IGNORE_CACHE" ] && [ -f "$cache_file" ] && read -r prev_ref prev_ver_type < "$cache_file" &&
                        [ -n "$prev_ref" ] && [ -n "$prev_ver_type" ]; then
                            gh_ref="$prev_ref" gh_ver_type="$prev_ver_type"
                    else
                        rm -f "${cache_file:-???}"
                    fi
            esac
        fi
    fi

    if [ -n "$gh_ref" ]; then
        set_res_vars "$@"
        return 0
    fi

    try_mkdir -p "$ref_fetch_tmp_dir" || return 1
    rm -f "$ref_ucl_err_file"

    eval "main_branch=\"\${MAIN_BRANCH_${component}}\""
    case "$gh_channel" in
        release)
            gh_ver_type=version
            [ -n "$version" ] && grep_ptrn="^v${version}$" ;;
        snapshot)
            gh_ver_type=commit
            branches="$main_branch"
            if [ -n "$version" ]; then
                grep_ptrn="^${version}$"
            fi ;;
        branch=*)
            gh_ver_type=commit
            branches="${gh_channel#*=}"
            if [ -n "$version" ]; then
                grep_ptrn="^${version}$"
            fi ;;
        commit=*)
            gh_ver_type=commit
            local gh_hash="${gh_channel#*=}"

            if [ "${#gh_hash}" = 40 ]; then
                # if upd. ch. is 'commit', the upd. ch. string includes commit hash -
                #    if it's 40-char long, use it directly without API query
                gh_ref="$gh_hash"
            else
                branches="$(
                    uclient-fetch "${gh_url_api}/branches" -O-  2> "$ref_ucl_err_file" |
                        { jsonfilter -e '@[@]["name"]'; cat 1>/dev/null; }
                )"
                [ -n "$branches" ] || {
                    error_out "Failed to get $component branches via GH API (url: '${gh_url_api}/branches')."
                    [ -f "$ref_ucl_err_file" ] &&
                        log_msg "uclient-fetch log:${_NL_}$(cat "$ref_ucl_err_file")"
                        rm -f "$ref_ucl_err_file"
                    return 1
                }
                rm -f "$ref_ucl_err_file"
                grep_ptrn="^${gh_hash}"
            fi ;;
        *)
            error_out "Invalid update channel '$gh_channel'."
            return 1
    esac

    # Get ref via GH API
    [ -z "$gh_ref" ] && gh_ref="$(get_and_process_ref "$gh_channel" "$grep_ptrn" "$branches" "$main_branch" "$ref_ucl_err_file")"

    if [ -z "$gh_ref" ] && [ -f "$ref_ucl_err_file" ] && ! grep -q "Download completed" "$ref_ucl_err_file"; then
        error_out "Failed to get $component GitHub download URL for $gh_ver_type '$version' (update channel: '$gh_channel')." \
            "uclient-fetch log:${_NL_}$(cat "$ref_ucl_err_file")"
        ref_fetch_rv=1
    fi
    rm -rf "${ref_fetch_tmp_dir:-?}"
    [ "$ref_fetch_rv" = 0 ] || return 1

    # validate resulting ref
    case "$gh_ref" in
        *[^"$_NL_"]*"${_NL_}"*[^"${_NL_}"]*)
            error_out "Got multiple $component download URLs for version '$version'." \
                "If using commit hash, please specify the complete commit hash string."
            return 1 ;;
        ''|*[!a-zA-Z0-9._-]*)
            error_out "Failed to get $component GitHub download URL for $gh_ver_type '$version' (update channel: '$gh_channel')."
            return 1
    esac

    # write the query result to cache
    try_mkdir -p "$ref_cache_dir" &&
    printf '%s\n' "$gh_ref $gh_ver_type" > "${ref_cache_dir}/${cache_filename}"

    set_res_vars "$@"
    :
}

# get update channel and version from local frontend file
# 1 - var name for version output
# 2 - var name for upd. channel output
# 3 - path to file
get_frontend_spec() {
    local me=get_frontend_spec rv=0 failed_spec='' spec_res='' spec_line
        spec_path="$3"

    are_var_names_safe "$1" "$2" || return 1
    [ -n "$3" ] || { error_out "$me: missing args."; return 1; }

    # assumes string enclosed in FS and no prior FS present in the line
    spec_res="$(
        awk -v v_ptrn_1="^[ 	]*const UI_VERSION[ 	]*=" -v v_ptrn_2="^[-a-zA-Z0-9_.]+$" \
            -v u_ptrn_1="^[ 	]*const UI_UPD_CHANNEL[ 	]*=" -v u_ptrn_2="^[-a-zA-Z0-9_.=]+$" \
            -F "'" '
                BEGIN{rv=1}
                {
                    if (v_match_res != "" && u_match_res != "") {rv = v_match_res + u_match_res; exit}
                }
                $0~v_ptrn_1 {
                    v_match_res=2
                    if ( $2~v_ptrn_2 ) {print "version:" $2; v_match_res=0}
                    next
                }
                $0~u_ptrn_1 {
                    u_match_res=3
                    if ( $2~u_ptrn_2 ) {print "upd_channel:" $2; u_match_res=0}
                    next
                }
                END{exit rv}
            ' "$spec_path"
        )" || {
            rv=$?
            case $rv in
                2) failed_spec=version ;;
                3) failed_spec="update channel" ;;
                *) failed_spec="version and update channel" ;;
            esac
            error_out "$me: Failed to get frontend $failed_spec from file '$spec_path'."
        }

        local IFS="$_NL_"
        for spec_line in $spec_res; do
            case "$spec_line" in
                '') continue ;;
                version:*) eval "$1"='${spec_line#*:}' ;;
                upd_channel:*) eval "$2"='${spec_line#*:}' ;;
                *) error_out "$me: got unexpected string when parsing file '$spec_path'."; return 1
            esac
        done
        return $rv
}

# Get version and update channel from local file or from repo
# 1 - var name for version output
# 2 - var name for update channel output
# 3 - component (BACKEND|FRONTEND)
# 4 - origin (local|remote)
# Error codes: 1 - failed to get version, 2 - got invalid version
get_component_spec() {
    local gv_version='' gv_upd_channel='' me=get_component_spec \
        gv_component="$3" gv_origin="$4"

    are_var_names_safe "$1" "$2" || return 1

    # get update channel
    case "$gv_component" in
        BACKEND) gv_upd_channel="$UPD_CHANNEL" ;;
        FRONTEND) get_frontend_spec gv_version gv_upd_channel "$QOSMATE_VERSION_FILE_FRONTEND" || return 1 ;;
        *) error_out "$me: invalid component '$gv_component'."; return 1
    esac

    # get version
    case "$gv_origin" in
        local) [ "$gv_component" = BACKEND ] && gv_version="$VERSION" ;;
        remote) get_gh_ref_data "$gv_component" "$gv_upd_channel" "" gv_version _ _ || return 1 ;;
        *) error_out "$me: invalid origin '$gv_origin'."; return 1
    esac

    : "$gv_version" # Silence shellcheck warning

    eval "$1"='$gv_version'
    eval "$2"='$gv_upd_channel'

    :
}

# Check and print versions for local and remote backend and frontend
# sets global variables: $BACKEND_upd_avail, $FRONTEND_upd_avail
# (optional) '-n' to not print update tip
# (optional) '-i' to ignore cache
# (optional) '-c <BACKEND|FRONTEND>''
# Return codes:
# 0 - no update
# 1 - error
# 254 - update available
check_version() {
    local origin notify_origin components='' components_arg='' component notify_component cv_version cv_upd_channel local_version \
        upd_avail='' no_print_tip=

    while getopts ":c:ni" opt; do
        case ${opt} in
            c) components_arg=$OPTARG ;;
            n) no_print_tip=1 ;;
            i) IGNORE_CACHE=1 ;; # global var
            *) ;;
        esac
    done

    components="${components_arg:-"$QOSMATE_COMPONENTS"}"
    for component in $components; do
        case "$component" in
            BACKEND) notify_component=Backend ;;
            FRONTEND) notify_component=Frontend ;;
            *) error_out "check_version: invalid component '$component'."; return 1
        esac

        print_msg "$notify_component versions:"

        local upd_ch_printed=
        for origin in local remote; do
            case "$origin" in
                local) notify_origin=Current ;;
                remote) notify_origin=Latest
            esac

            get_component_spec cv_version cv_upd_channel "$component" "$origin" || {
                error_out "Failed to get $origin $component version." \
                    "To force re-installation of $component, use the command 'service qosmate update -f -c $component'."
                return 1
            }

            [ -z "$upd_ch_printed" ] && { print_msg "  Update channel: $cv_upd_channel"; upd_ch_printed=1; }

            case "$origin" in
                local) local_version="$cv_version" ;;
                remote)
                    if [ "$cv_version" = "$local_version" ]; then
                        unset "${component}_upd_avail"
                    else
                        upd_avail=1
                        eval "${component}_upd_avail=1"
                    fi
            esac
            printf '%s\n' "  $notify_origin version: $cv_version"
        done
    done

    if [ -n "$upd_avail" ]; then
        [ -z "$no_print_tip" ] && printf '\n%s\n%s\n' "A new version of QoSmate is available." \
            "To update, run: /etc/init.d/qosmate update"
        return 254
    else
        printf '\n%s\n' "QoSmate components '$components' are up to date."
    fi
    :
}

# Optional args:
# -s <path> : simulate update (intended for testing: service qosmate update -s <path_to_new_ver> -v <version>)
# -c <frontend|backend> : only update specified component
# -v [<version>|package[=<version>]|release|snapshot|branch=<branch>|commit=<commit_hash>] : version string
# -U <update_channel> : force this update channel (overrides the upd. channel derived from '-v' option)
# -W <version> : force this version (overrides the version derived from '-v' option)
# -f : force update
# -i : ignore previous cache results
update() {
    upd_failed() {
        rm -rf "${QOSMATE_UPD_DIR:-?}"
        [ -n "$*" ] && error_out "$@"
        error_out "Failed to update QoSmate."
    }

    pkg_update_not_impl() {
        upd_failed "Update channel 'package' not implemented."
    }

    # 1 - fixup type: <file|dir>
    # 2 - path to fixup file
    # 3 - distribution dir
    fixup_paths() {
        local fixup_line='' fetched_path dest_path me=fixup_paths \
            fixup_type="$1" fixup_file="$2" dist_dir="$3"
        [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ] || { error_out "$me: missing args."; return 1; }

        while IFS='' read -r fixup_line || [ -n "$fixup_line" ]; do
            case "$fixup_line" in
                "#"*) continue ;; # skip comments
                *=*)
                    fetched_path="${fixup_line%%=*}"
                    fetched_path="${fetched_path%/}"
                    dest_path="${fixup_line#*=}"
                    dest_path="${dest_path%/}"
                    [ -n "$fetched_path" ] && [ "$fetched_path" != "/" ] && [ -n "$dest_path" ] ||
                        { error_out "$me: invalid line in fixup file: '$fixup_line'."; return 1; }
                    # warn about and skip non-existing files and directories
                    [ -e "${dist_dir:-???}${fetched_path:-???}" ] ||
                        { log_msg -warn "$me: path '${dist_dir}${fetched_path}' does not exist."; continue; }
                    case "$fixup_type" in
                        dir)
                            try_mkdir -p "${dist_dir}${dest_path}" &&
                            mv "${dist_dir:-???}${fetched_path:-???}"/* "${dist_dir:-???}${dest_path:-???}/" ;;
                        file)
                            try_mkdir -p "${dist_dir}${dest_path%/*}" &&
                            mv "${dist_dir:-???}${fetched_path:-???}" "${dist_dir}${dest_path}" ;;
                        *) error_out "$me: invalid fixup type '$fixup_type'."; return 1
                    esac || {
                        error_out "Failed to move $fixup_type '${dist_dir}${fetched_path}' to '${dist_dir}${dest_path}'."
                        return 1
                    } ;;
                *) continue
            esac
        done < "$fixup_file" || return 1
        :
    }

    unexp_arg() { upd_failed "update: unexpected argument '$1'."; }

    local file origin new_file_list exec_files sim_path='' req_ver='' ver_type ver_str_arg='' \
        extract_dir='' dist_dir='' upd_version='' tarball_url='' file_list_query_path \
        backend_upd_req='' upd_component_arg='' upd_components='' req_upd_components='' \
        req_upd_channel upd_channel='' def_upd_channel='' force_upd_channel='' force_ver='' force_update=''

    IGNORE_CACHE=
    while getopts ":s:v:c:U:W:fi" opt; do
        case ${opt} in
            c) upd_component_arg=$OPTARG ;;
            s) sim_path=$OPTARG ;;
            v) ver_str_arg=$OPTARG force_update=1 ;;
            U) force_upd_channel=$OPTARG force_update=1 ;;
            W) force_ver=$OPTARG force_update=1 ;;
            f) force_update=1 ;;
            i) IGNORE_CACHE=1 ;; # global var
            *) unexp_arg "$OPTARG"; return 1
        esac
    done
    shift $((OPTIND-1))
    [ -z "${*}" ] || { unexp_arg "${*}"; return 1; }

    case "$upd_component_arg" in
        '') ;;
        backend|BACKEND) upd_component_arg=BACKEND ;;
        frontend|FRONTEND) upd_component_arg=FRONTEND ;;
        *) upd_failed "Unexpected component '$upd_component_arg'"; return 1
    esac
    req_upd_components="${upd_component_arg:-"$QOSMATE_COMPONENTS"}"

    if [ -n "$force_update" ]; then
        upd_components="$req_upd_components"
    else
        check_version -n -c "$req_upd_components"
        case $? in
            0) return 0 ;;
            1) return 1 ;;
            254)
                print_msg "Updates available. Do you want to update? [y/N] "
                read -r answer
                case "$answer" in
                    y|Y) ;;
                    *)
                        print_msg "Update cancelled."
                        return 0
                esac
        esac
        for component in $req_upd_components; do
            eval "[ -n \"\${${component}_upd_avail}\" ]" && upd_components="${upd_components}${component} "
        done
    fi

    log_msg "Updating QoSmate components '$upd_components'..."

    # parse version string from arguments into $req_upd_channel, $req_ver
    case "$ver_str_arg" in
        '') ;;
        package*)
            pkg_update_not_impl
            return 1 ;;
        release)
            req_upd_channel="${ver_str_arg}" req_ver='' ;;
        snapshot)
            req_upd_channel="${ver_str_arg}" req_ver='' ;;
        commit=*)
            req_upd_channel="${ver_str_arg}" req_ver="${ver_str_arg#*=}" ;;
        branch=*)
            req_upd_channel="$ver_str_arg" req_ver='' ;;
        [0-9]*|v[0-9]*)
            req_upd_channel=release
            req_ver="${ver_str_arg#*=}"
            req_ver="${req_ver#v}"
            ;;
        *)
            upd_failed "Invalid version string '$ver_str_arg'."
            return 1
    esac

    req_upd_channel="${force_upd_channel:-"${req_upd_channel}"}"
    req_ver="${force_ver:-"${req_ver}"}"

    # updating multiple components to same commit hash makes no sense
    case "$req_upd_channel" in
        snapshot|branch=*|commit=*)
            case "$upd_components" in BACKEND*FRONTEND|FRONTEND*BACKEND)
                [ -n "$req_ver" ] && {
                    upd_failed "Can not update multiple components '$upd_components' to version '$req_ver'."
                    return 1
                }
            esac
    esac

    rm -rf "${QOSMATE_UPD_DIR:-?}"
    try_mkdir -p "$QOSMATE_UPD_DIR" || { upd_failed; return 1; }

    if [ -n "$sim_path" ]
    then
        log_msg "Updating in simulation mode."
        [ -d "$sim_path" ] || { upd_failed "Update simulation directory '$sim_path' does not exist."; return 1; }
        [ -n "${req_ver}" ] || { upd_failed "Specify new version."; return 1; }
        def_upd_channel=release
        upd_version="${req_ver}"
        : "${req_upd_channel:="${def_upd_channel}"}"

        for component in $upd_components; do
            [ -d "${sim_path}/${component}" ] ||
                { upd_failed "Simulation source directory doesn't have ${component} directory"; return 1; }
        done
        cp -rT "$sim_path" "$QOSMATE_UPD_DIR"
    fi

    case "$upd_components" in *BACKEND*)
        backend_upd_req=1
    esac

    for component in $upd_components; do
        upd_channel=
        # set default update channel
        case "$component" in
            BACKEND)
                def_upd_channel="${UPD_CHANNEL:-release}" ;;
            FRONTEND)
                if [ -n "$req_upd_channel" ]; then
                    :
                elif [ ! -f "$QOSMATE_VERSION_FILE_FRONTEND" ]; then
                    def_upd_channel="${UPD_CHANNEL:-release}"
                else
                    get_frontend_spec _ def_upd_channel "$QOSMATE_VERSION_FILE_FRONTEND" || {
                        error_out "Failed to get current FRONTEND update channel. Defaulting to 'release'."
                        def_upd_channel=release
                    }
                fi
        esac

        upd_channel="${req_upd_channel:-"${def_upd_channel}"}"

        dist_dir="${QOSMATE_UPD_DIR}/${component}"
        case "$upd_channel" in
            package)
                pkg_update_not_impl
                return 1 ;;
            *)
                if [ -n "$sim_path" ]
                then
                    log_msg "" "Updating $component to version '$upd_version' (update channel: '$upd_channel')."
                else
                    get_gh_ref_data "$component" "$upd_channel" "$req_ver" upd_version tarball_url ver_type || return 1
                    case "$upd_channel" in
                        commit=*)
                            # set update channel to 'commit=<full_commit_hash>'
                            upd_channel="${upd_channel%=*}=${upd_version}"
                    esac
                    log_msg "" "Downloading $component, $ver_type '$upd_version' (update channel: '$upd_channel')."
                    fetch_qosmate_component "$component" "$tarball_url" || { upd_failed; return 1; }
                fi

                if [ -n "$backend_upd_req" ]; then
                    file_list_query_path="${QOSMATE_UPD_DIR}/BACKEND${QOSMATE_SERVICE_PATH}"
                else
                    file_list_query_path="${QOSMATE_SERVICE_PATH}"
                fi

                new_file_list="$(/bin/sh "$file_list_query_path" print_file_list "$component" ALL)" &&
                write_str_to_file "$new_file_list" "${dist_dir}/new_file_list" &&
                [ -n "$new_file_list" ] || {
                    upd_failed "Failed to get file list from the fetched QoSmate version." \
                        "NOTE: QoSmate versions prior to v1.2.0 do not support the new update mechanism."
                    return 1
                }

                exec_files="$(/bin/sh "$file_list_query_path" print_file_list "$component" EXEC)" &&
                write_str_to_file "$exec_files" "${dist_dir}/exec_files" || { upd_failed; return 1; }
                eval "ver_${component}"='$upd_version'
                eval "upd_channel_${component}"='$upd_channel'

                # fix-up paths if needed
                local dir_fixup_file="${dist_dir}/dir_fixups.txt"
                local path_fixup_file="${dist_dir}/file_fixups.txt"

                if [ -s "$dir_fixup_file" ]; then
                    fixup_paths "dir" "$dir_fixup_file" "$dist_dir" || { upd_failed "Failed to fix-up dir paths."; return 1; }
                fi

                if [ -s "$path_fixup_file" ]; then
                    fixup_paths "file" "$path_fixup_file" "$dist_dir" || { upd_failed "Failed to fix-up file paths."; return 1; }
                fi
        esac
    done

    [ -n "$backend_upd_req" ] &&
        /bin/sh "${QOSMATE_UPD_DIR}/BACKEND${QOSMATE_SERVICE_PATH}" post_update_1

    case "$ver_str_arg" in
        package*)
            pkg_update_not_impl
            return 1 ;;
        *)
            install_qosmate_files "$QOSMATE_UPD_DIR" "$upd_components" || { upd_failed; return 1; }
            rm -rf "${QOSMATE_UPD_DIR:-?}"
    esac

    [ -n "$backend_upd_req" ] && chmod +x "$QOSMATE_SERVICE_PATH"
    /bin/sh "$QOSMATE_SERVICE_PATH" post_update_2 # post_update_2 is called when updating either component

    log_msg "QoSmate components '$upd_components' have been successfully updated."

    if [ -n "$backend_upd_req" ] && "$QOSMATE_SERVICE_PATH" enabled; then
        log_msg "" "Restarting QoSmate."
        ${QOSMATE_SERVICE_PATH} restart
    fi
    :
}

# 1 - path to upper distribution dir (containing a dir for each component)
# 2 - component(s): <BACKEND|FRONTEND|"BACKEND FRONTEND">
install_qosmate_files() {
    inst_failed() {
        [ -n "$1" ] && error_out "$1"
        error_out "Failed to install new $component files."
    }

    local file preinst_path curr_files new_file_list new_file_list exec_files='' \
        dist_dir ver_file_path frontend_updated='' \
        upper_dist_dir="$1" components="$2" version upd_channel

    for component in $components; do
        log_msg "" "Installing new $component files..."

        eval "version=\"\${ver_${component}}\" upd_channel=\"\${upd_channel_${component}}\""
        [ -n "$version" ] && [ -n "$upd_channel" ] ||
            { inst_failed "Internal error: failed to get version and update channel for component '$component'."; return 1; }

        dist_dir="${upper_dist_dir}/${component}"

        # read new file list
        new_file_list="$(cat "${dist_dir}/new_file_list")" && [ -n "$new_file_list" ] &&
        exec_files="$(cat "${dist_dir}/exec_files")" ||
            {
                rm -f "${dist_dir}/new_file_list" "${dist_dir}/exec_files"
                inst_failed "Failed to read new file list."
                return 1
            }
        rm -f "${dist_dir}/exec_files"

        # get current file list
        curr_files="$(print_file_list "$component" ALL)" || { inst_failed; return 1; }

        eval "ver_file_path=\"\${QOSMATE_VERSION_FILE_${component}}\""

        # version and update channel string replacement vars
        local ver_ptrn_prefix ver_repl_str upd_ch_repl_str
        case "$component" in
            BACKEND)
                ver_ptrn_prefix=
                ver_repl_str="VERSION=\"$version\""
                upd_ch_repl_str="UPD_CHANNEL=\"$upd_channel\"" ;;
            FRONTEND)
                ver_ptrn_prefix="const UI_"
                ver_repl_str="VERSION = '$version';"
                upd_ch_repl_str="UPD_CHANNEL = '$upd_channel';" ;;
        esac

        # set version and update channel in component's main file
        local preinst_ver_file_path="${dist_dir}${ver_file_path}"

        sed -i "
            /^\s*${ver_ptrn_prefix}VERSION\s*=/{s/.*/${ver_ptrn_prefix}${ver_repl_str}/;}
            /^\s*${ver_ptrn_prefix}UPD_CHANNEL\s*=/{s/.*/${ver_ptrn_prefix}${upd_ch_repl_str}/;}" \
                "$preinst_ver_file_path" &&
                    # verify that substitution worked
                    grep -q "^${ver_ptrn_prefix}${ver_repl_str}" "$preinst_ver_file_path" &&
                    grep -q "^${ver_ptrn_prefix}${upd_ch_repl_str}" "$preinst_ver_file_path" ||
                        { inst_failed "Failed to set version in file '$preinst_ver_file_path'."; return 1; }

        # Check for changed files
        local curr_reg_file changed_files='' unchanged_files='' man_changed_files='' \
            prefixed_curr_reg_file="${dist_dir}/prefixed_reg_${component}.md5"
        eval "curr_reg_file=\"\${QOSMATE_FILES_REG_PATH_${component}}\""

        if [ -s "$curr_reg_file" ]; then
            # prefix file paths in the reg file for md5sum comparison
            sed -E "/^$/d;s~([^ 	]+$)~${dist_dir}\\1~" "$curr_reg_file" > "$prefixed_curr_reg_file"
            unchanged_files="$(md5sum -c "$prefixed_curr_reg_file" 2>/dev/null |
                sed -n "/:\s*OK\s*$/{s/\s*:\s*OK\s*$//;s~^\s*${dist_dir}~~;p;}")"
            rm -f "$prefixed_curr_reg_file"

            # remove unchanged files from $new_file_list to reliably get a list of files to copy
            changed_files="$(
                printf '%s\n' "$unchanged_files" | awk '
                    NR==FNR {unch[$0];next}
                    ($0=="" || $0 in unch) {next}
                    {print}
                ' - "${dist_dir}/new_file_list"
            )"

            # Detect manually modified files
            man_changed_files="$(
                md5sum -c "$curr_reg_file" 2>/dev/null |
                sed -n "/:\s*FAILED\s*$/{s/\s*:\s*FAILED\s*$//;p;}" |
                awk '
                    NR == FNR {new[$0]; next}
                    $0 in new {print}
                ' "${dist_dir}/new_file_list" -
            )"

            # Add manually modified files to changed files
            if [ -n "$man_changed_files" ]; then
                changed_files="$(printf '%s\n' "${changed_files}${_NL_}${man_changed_files}" | sort -u | sed '/^$/d')"
            fi
        else
            changed_files="$new_file_list"
        fi

        local IFS="$_NL_"
        for file in $unchanged_files; do
            [ -n "$file" ] || continue
            log_msg "File '$file' did not change - not updating."
        done

        local mod_files_bk_dir="/tmp/qosmate_old_modified_files"
        for file in $man_changed_files; do
            [ -n "$file" ] && [ -f "$file" ] || continue
            log_msg -warn "File '$file' was manually modified - overwriting."
            if try_mkdir -p "$mod_files_bk_dir" && cp "$file" "${mod_files_bk_dir}/${file##*/}"; then
                log_msg "Saved a backup copy of manually modified file to ${mod_files_bk_dir}/${file##*/}"
            else
                log_msg -warn "Can not create a backup copy of manually modified file '$file' - overwriting anyway."
            fi
        done

        # Copy changed files
        for file in $changed_files
        do
            preinst_path="${dist_dir}${file}"

            log_msg "Copying file '${file}'."
            try_mkdir -p "${file%/*}" && cp "$preinst_path" "$file" ||
                { inst_failed "Failed to copy file '$preinst_path' to '$file'."; return 1; }
            [ "$component" = FRONTEND ] && frontend_updated=1
        done

        # delete obsolete files
        for file in ${curr_files}
        do
            [ -f "$file" ] || continue

            # check for $file in $new_file_list, allowing newline as list delimiter
            case "$new_file_list" in
                "$file"|"${file}${_NL_}"*|*"${_NL_}${file}"|*"${_NL_}${file}${_NL_}"*)
                    continue ;;
                *)
                    log_msg "Deleting obsolete file '$file'."
                    rm -f "$file"
            esac
        done

        # make files executable
        set -- $exec_files # relying on IFS=\n
        chmod +x "$@" || { inst_failed "Failed to make files executable."; return 1; }

        # save the md5sum registry file if needed
        if [ -n "$changed_files" ] || [ ! -s "$curr_reg_file" ]; then
            # make md5sum registry of new files
            # shellcheck disable=SC2046
            set -- $(printf '%s\n' "$new_file_list" | sed "/^$/d;s~^\s*~${dist_dir}~") # relying on IFS=\n
            md5sums="$(md5sum "$@")" && [ -n "$md5sums" ] &&
            try_mkdir -p "${curr_reg_file%/*}" &&
            printf '%s\n' "$md5sums" | sed "s~\s${dist_dir}~ ~" > "$curr_reg_file" ||
                { inst_failed "Failed to register new files."; return 1; }
        fi
        IFS="$DEFAULT_IFS"
    done

    # Restart rpcd only if any frontend files were updated
    [ -n "$frontend_updated" ] && /etc/init.d/rpcd restart
    :
}

### Config-related functions

preserve_config_files() {
    local path save_req='' \
        paths="$QOSMATE_MAIN_SCRIPT $QOSMATE_SERVICE_PATH $QOSMATE_HOTPLUG_SCRIPT $QOSMATE_D" \
        tmp_file="/tmp/qosmate_sysupgr" \
        sysupgr_file="/etc/sysupgrade.conf"

    rm -f "$tmp_file"

    printf '\n'

    if [ "$PRESERVE_CONFIG_FILES" = 1 ]; then
        for path in $paths; do
            grep -qxF "$path" "$sysupgr_file" && continue
            echo "$path" >> "$tmp_file" || return 1
            save_req=1
        done
        if [ -n "$save_req" ]; then
            cat "$tmp_file" >> "$sysupgr_file" &&
            print_msg "Config files have been added to $sysupgr_file for preservation."
        else
            print_msg "$sysupgr_file already lists qosmate config files."
        fi
    else
        print_msg "Preservation of config files is disabled."

        # Remove the config files from sysupgrade.conf if they exist
        [ -f "$sysupgr_file" ] || return 0
        cp "$sysupgr_file" "$tmp_file" || return 1
        for path in $paths; do
            grep -qxF "$path" "$tmp_file" || continue
            sed -i "\|^$path$|d" "$tmp_file" || return 1
            save_req=1
        done

        if [ -n "$save_req" ]; then
            mv "$tmp_file" "$sysupgr_file" &&
            print_msg "Config files have been removed from $sysupgr_file."
        else
            print_msg "$sysupgr_file does not list qosmate config files."
        fi
    fi

    rm -f "$tmp_file"
    :
}

# return codes:
# 0 - OK
# 1 - error
# 154 - error in default config
# 155 - missing sections and/or options in user config
parse_config() {
    local file miss_sec_f="/tmp/qosmate-missing-sections" miss_opt_f="/tmp/qosmate-missing-options"
    for file in "$QOSMATE_CFG_FILE" "$QOSMATE_DEFAULTS_FILE"; do
        [ -f "$file" ] || {
            error_out "$file not found."
            return 1
        }
    done

    awk -v miss_sec_f="$miss_sec_f" -v miss_opt_f="$miss_opt_f" \
        -v bad_quotes="(\"|'.*'.*')" -v p1="'[^']*$" -v p2=".*'" ' \
    function get_val(s){
        if (sub(p1,"",s) && sub(p2,"",s)) return s; else return "ERROR"
    }

    BEGIN{
        rv=0
        # Primary settings must be printed first
        def_prim_arr["settings:UPRATE"]
        def_prim_arr["settings:DOWNRATE"]
    }

    # serialize user config into user_sections_arr, user_opts_arr
    NR == FNR {
        if ($0 ~ bad_quotes) next # ignore lines with double-quotes or more than 2 single-quotes

        if ( $0 ~ /^[ 	]*config[ 	]/ ) {
            section = get_val($0)
            if (section == "ERROR" || section !~ /^[a-zA-Z0-9_]+$/) next # ignore invalid section declarations
            user_sections_arr[section]
            next
        }

        if ( $0 ~ /^[ 	]*option[ 	]/ ) {
            if ( ! section || $2 !~ /^[a-zA-Z0-9_]+$/ ) next # ignore options w/ invalid keys or outside section
            val = get_val($0)
            if ( val == "ERROR" || ! val ) next # ignore options w/ invalid or empty values
            user_opts_arr[section ":" $2] = val
        }
        next
    }

    # serialize default config into def_prim_arr, def_secondary_arr
    # and check against it for missing sections or options in user config
    FNR == 1 { section = ""; section_ind = 0 }
    $0 ~ /^[ 	]*($|#)/ {next} # ignore comments and empty lines

    /^[ 	]*config[ 	]/ {
        if ($0 ~ bad_quotes) {rv=154;exit}
        section = get_val($0)
        if (section == "ERROR" || section !~ /^[a-zA-Z0-9_]+$/) {rv=154; exit}
        if (section in user_sections_arr) {section_ind++;next}
        # user config is missing section
        missing_sections = missing_sections section "=" section_ind "\n"
        print section >> miss_sec_f
        section_ind++
        next
    }

    /^[ 	]*option[ 	]/ {
        if ($0 ~ bad_quotes) {rv=154;exit}
        if ( ! section || $2 !~ /^[a-zA-Z0-9_]+$/ ) {rv=154; exit}
        val = get_val($0)
        if (val == "ERROR") {rv=154; exit}
        ser_opt = section ":" $2
        if (ser_opt in def_prim_arr) def_prim_arr[ser_opt] = val
            else def_secondary_arr[ser_opt] = val
        if (! val) next     # do not check user opts vs def opts without value
        if (ser_opt in user_opts_arr) next
        # user config is missing option
        missing_opts =  missing_opts ser_opt "=" val "\n"
        print ser_opt >> miss_opt_f
    }

    END{
        if (rv == 1 || rv == 154) exit rv
        if (missing_sections) {
            rv = 155
            print "missing_sections=\"" missing_sections "\""
        }
        if (missing_opts) {
            rv = 155
            print "missing_opts=\"" missing_opts "\""
        }

        for (ser_opt in def_prim_arr) {if (! def_prim_arr[ser_opt]) exit 154} # check that primary opts are set in def config
        printf "%s", "ser_def_opts=\""
        for (ser_opt in def_prim_arr) {print ser_opt "=" def_prim_arr[ser_opt]}
        for (ser_opt in def_secondary_arr) print ser_opt "=" def_secondary_arr[ser_opt]
        print "\""
        exit rv
    }' "$QOSMATE_CFG_FILE" "$QOSMATE_DEFAULTS_FILE"
    rv=$?

    [ $rv != 155 ] && { rm -f "$miss_sec_f" "$miss_opt_f"; return $rv; }

    for file in "$miss_sec_f" "$miss_opt_f"; do
        [ -f "$file" ] || continue
        log_msg -warn "" "Missing config ${file##*-}:" "$(cat "$file")"
        rm -f "$file"
    done
    return 155
}

load_and_fix_config() {
    export QOSMATE_CONFIG_LOADED=
    try_load_and_fix_config "$@" && { QOSMATE_CONFIG_LOADED=1; return 0; }
    # 155 = config loaded but some options/sections missing
    [ $? != 155 ] && {
        error_out "Failed to load config."
        uci revert qosmate 2>/dev/null
    }
    return 1
}

# return codes:
# 0 - check or fix OK
# 1 - error
try_load_and_fix_config() {
    parse_ser_opt() {
        case "$4" in
            '') return 0 ;;
            *:*=*) ;;
            *) error_out "parse_ser_opt: unexpected input '$4'."; return 1
        esac
        local gso_section="${4%%:*}" gso_val="${4#*=}" gso_opt_name="${4#*:}"
        gso_opt_name="${gso_opt_name%"=$gso_val"}"
        eval "$1=\"$gso_section\" $2=\"$gso_opt_name\" $3=\"$gso_val\""
    }

    local nofix=
    [ "$1" = '-nofix' ] && nofix=1

    if [ ! -f "$QOSMATE_CFG_FILE" ]; then
        [ -n "$nofix" ] && return 1
        log_msg -warn "Config file not found, restoring from qosmate-defaults."
        # qosmate-defaults is checked for and restored if required by start() using the update() routine
        [ -f "$QOSMATE_DEFAULTS_FILE" ] || { error_out "$QOSMATE_DEFAULTS_FILE not found."; return 1; }
        uci import qosmate < "$QOSMATE_DEFAULTS_FILE" || {
            error_out "Failed to restore config."
            return 1
        }
        log_msg "Config file restored."
    fi

    local IFS="$DEFAULT_IFS" section section_name ser_opt opt_name def_val \
        parse_rv parse_res missing_sections='' missing_opts='' ser_def_opts=''

    # Validate config and generate $parse_res for config repair
    parse_res="$(parse_config)"
    parse_rv=$?

    case $parse_rv in
        0|155) ;;
        1) error_out "Failed to parse config."; return 1 ;;
        154) error_out "Failed to process $QOSMATE_DEFAULTS_FILE."; return 1 ;;
        *) error_out "Unexpected return code $parse_rv when parsing config."; return 1 ;;
    esac

    eval "$parse_res" || { error_out "Failed to parse config."; return 1; }

    if [ "$parse_rv" = 155 ] && [ -z "$nofix" ]; then
        log_msg "" "Adding missing sections and options from the default config."

        IFS="$_NL_"
        for section in $missing_sections; do
            [ -n "$section" ] || continue
            section_name="${section%%=*}"
            index="${section#*=}"
            log_msg "Adding config section '$section_name'."
            uci set "qosmate.${section_name}=${section_name}" &&
            uci reorder "qosmate.${section_name}=${index}" || return 1
        done
        for ser_opt in $missing_opts; do
            [ -n "$ser_opt" ] || continue
            parse_ser_opt section opt_name def_val "$ser_opt" || return 1
            [ -n "$section" ] && [ -n "$opt_name" ] && [ -n "$def_val" ] || continue
            log_msg "Adding option '$opt_name' to section '$section' with default value '$def_val'."
            uci set "qosmate.${section}.${opt_name}=${def_val}" ||
                { error_out "Failed to add config option."; return 1; }
        done
        IFS="$DEFAULT_IFS"
        uci commit qosmate || return 1
        parse_rv=0
    fi

    config_load qosmate || return 1

    IFS="$_NL_"
    for ser_opt in $ser_def_opts; do
        IFS="$DEFAULT_IFS"
        parse_ser_opt section opt_name def_val "$ser_opt" || return 1
        var_name="$opt_name"

        # Var name and def val overrides
        case "${section}:${opt_name}" in
            global:enabled) var_name=global_enabled ;;
            advanced:ACKRATE) def_val=$((UPRATE * 5 / 100)) ;;
            hfsc:GAMEUP) def_val=$((UPRATE*15/100+400)) ;;
            hfsc:GAMEDOWN) def_val=$((DOWNRATE*15/100+400)) ;;
            hfsc:netem_direction) var_name=NETEM_DIRECTION ;;
            # Autorate defaults (based on configured rates)
            autorate:enabled) var_name=AUTORATE_ENABLED ;;
            autorate:min_ul_rate) def_val=$((UPRATE * 25 / 100)); var_name=AUTORATE_MIN_UL ;;
            autorate:base_ul_rate) def_val="$UPRATE"; var_name=AUTORATE_BASE_UL ;;
            autorate:max_ul_rate) def_val=$((UPRATE * 105 / 100)); var_name=AUTORATE_MAX_UL ;;
            autorate:min_dl_rate) def_val=$((DOWNRATE * 25 / 100)); var_name=AUTORATE_MIN_DL ;;
            autorate:base_dl_rate) def_val="$DOWNRATE"; var_name=AUTORATE_BASE_DL ;;
            autorate:max_dl_rate) def_val=$((DOWNRATE * 105 / 100)); var_name=AUTORATE_MAX_DL ;;
            autorate:interval) var_name=AUTORATE_INTERVAL ;;
            autorate:latency_increase_threshold) var_name=AUTORATE_LAT_INC_THR ;;
            autorate:latency_decrease_threshold) var_name=AUTORATE_LAT_DEC_THR ;;
            autorate:reflectors) var_name=AUTORATE_REFLECTORS ;;
            autorate:refractory_increase) var_name=AUTORATE_REFRACT_INC ;;
            autorate:refractory_decrease) var_name=AUTORATE_REFRACT_DEC ;;
            autorate:adjust_up_factor) var_name=AUTORATE_ADJ_UP ;;
            autorate:adjust_down_factor) var_name=AUTORATE_ADJ_DOWN ;;
        esac

        NO_EXPORT='' config_get "$var_name" "$section" "$opt_name" "$def_val"
    done
    IFS="$DEFAULT_IFS"

    # Ensure valid values for DOWNRATE, UPRATE
    case "$ROOT_QDISC" in hfsc|hybrid)
        local opt val commit_req='' min_val=1000
        for opt in DOWNRATE UPRATE; do
            eval "val=\"\${${opt}}\""
            if ! [ "$val" -gt 0 ]; then
                log_msg -warn "$opt is '$val' for $ROOT_QDISC. Setting to minimum value of $min_val kbps."
                eval "$opt=$min_val"
                uci set "qosmate.settings.${opt}=${min_val}"
                commit_req=1
            fi
        done
        [ -z "$commit_req" ] || uci commit qosmate || return 1
    esac

    # Limit DOWNRATE based on BWMAXRATIO
    if [ "$UPRATE" -gt 0 ] && [ $((DOWNRATE > UPRATE*BWMAXRATIO)) -eq 1 ]; then
        print_msg "We limit the downrate to at most $BWMAXRATIO times the upstream rate to ensure no upstream ACK floods occur which can cause game packet drops"
        DOWNRATE=$((BWMAXRATIO*UPRATE))
    fi

    return $parse_rv
}

migrate_config() {
    try_migrate_config
    local rv=$?
    [ $rv = 0 ] || {
        error_out "Failed to migrate config."
        uci_tmp revert qosmate 2>/dev/null
    }
    rm -rf "$TMP_CFG_DIR"
    return $rv
}

try_migrate_config() {
    report_migration() {
        local msg="Link layer settings migrated from settings for qdisc '$1' to advanced section: preset=$2"
        [ -n "$3" ] && msg="$msg, overhead=$3"
        log_msg "$msg"
    }

    delete_premigration_settings() {
        local qdisc old_settings
        for qdisc in cake hfsc; do
            eval "old_settings=\"\${${qdisc}_old_settings}\""
            for setting in $old_settings; do
                uci_tmp -q delete "qosmate.$qdisc.$setting"
            done
        done
    }

    . /lib/functions.sh

    local commit_req=''

    [ -f "$QOSMATE_CFG_FILE" ] &&
    try_mkdir -p "$TMP_CFG_DIR" &&
    cp "$QOSMATE_CFG_FILE" "$TMP_CFG_FILE" || return 1

    # Check for and correct the custom_rules section
    awk "
        BEGIN{cor=3; inc=3}
        /^[  ]*config[  ]+qosmate[  ]+'custom_rules'[  ]*$/{inc=4}
        /^[  ]*config[  ]+custom_rules[  ]+'custom_rules'[  ]*$/{cor=4}
        END{exit cor inc}
    "  "$TMP_CFG_FILE"

    case "$?" in
        33)
            # No custom_rules section found
            ;;
        43)
            # Only correct section found
            ;;
        34)
            # Only incorrect section found - correct it
            commit_req=1
            print_msg "Incorrect custom_rules section found. Correcting..."
            sed -i "s/config qosmate 'custom_rules'/config custom_rules 'custom_rules'/" "$TMP_CFG_FILE"
            print_msg "custom_rules section corrected."
            ;;
        44)
            # Both correct and incorrect sections found - remove duplicate
            commit_req=1
            print_msg "Both incorrect and correct custom_rules sections found. Removing the incorrect one..."
            uci_tmp -q delete qosmate.@qosmate[0] || return 1
            print_msg "Incorrect custom_rules section removed."
            ;;
        *)
            error_out "Unexpected return code '$?' when checking the 'custom_rules' section."
            return 1
            ;;
    esac

    # Migrate link layer settings to advanced section based on active QDisc

    local root_qdisc qdisc settings setting val adv_preset \
        has_old_settings=0 \
        cake_old_settings="COMMON_LINK_PRESETS OVERHEAD MPU LINK_COMPENSATION ETHER_VLAN_KEYWORD" \
        hfsc_old_settings="LINKTYPE OH"
    : "$cake_old_settings" "$hfsc_old_settings"

    UCI_CONFIG_DIR="$TMP_CFG_DIR" config_load qosmate || return 1
    config_get root_qdisc settings ROOT_QDISC
    config_get adv_preset advanced COMMON_LINK_PRESETS

    # Check for old settings, migrate settings for CAKE or set OLD_${setting} vars for HFSC
    for qdisc in cake hfsc; do
        eval "settings=\"\${${qdisc}_old_settings}\""
        for setting in $settings; do
            config_get val "$qdisc" "$setting"
            [ -n "$val" ] || continue

            has_old_settings=1

            if [ "$qdisc" = "$root_qdisc" ]; then
                eval "OLD_${setting}=\"${val}\""
                # Immediately migrate CAKE settings
                [ "$qdisc" = cake ] && { uci_tmp set "qosmate.advanced.$setting=$val" || return 1; }
            fi
        done
    done

    # Skip migration if not needed
    if [ -n "$adv_preset" ]; then
        if [ "$has_old_settings" = 0 ]; then
            [ -n "$commit_req" ] || return 0
            commit_tmp_config
            return $?
        fi
        delete_premigration_settings
        commit_tmp_config
        return $?
    fi

    # Clean up old settings
    delete_premigration_settings

    # Finalize CAKE migration
    [ "$root_qdisc" = cake ] && {
        if [ -n "$OLD_COMMON_LINK_PRESETS" ]; then
            commit_tmp_config || return 1
            report_migration cake "$OLD_COMMON_LINK_PRESETS" "$OLD_OVERHEAD"
        else
            uci_tmp set qosmate.advanced.COMMON_LINK_PRESETS=ethernet &&
            commit_tmp_config || return 1
            log_msg "Added missing link layer preset: ethernet."
        fi
        return 0
    }

    # Migrate settings for non-cake root qdiscs

    # Use default "ethernet" if LINKTYPE not set
    case "${OLD_LINKTYPE:-ethernet}" in
        atm)
            COMMON_LINK_PRESETS=atm
            # ATM used the $OH variable - only set if different from default
            [ -n "$OLD_OH" ] && [ "$OLD_OH" != "44" ] && {
                OH="$OLD_OH"
                uci_tmp set qosmate.advanced.OVERHEAD="$OLD_OH" || return 1
            }
            ;;
        docsis)
            COMMON_LINK_PRESETS=docsis
            ;;
        *)
            COMMON_LINK_PRESETS=ethernet
            ;;
    esac

    uci_tmp set qosmate.advanced.COMMON_LINK_PRESETS="$COMMON_LINK_PRESETS" &&
    commit_tmp_config || return 1
    report_migration "$root_qdisc" "$COMMON_LINK_PRESETS" "$OH"
    :
}

### Servce functions

# Check if qosmate is active
# Return codes:
# 0 - active
# 1 - inactive
# 250 - error
is_qosmate_active() {
    [ -n "$1" ] || { error_out "is_qosmate_active: specify WAN device"; return 250; }
    tc qdisc show dev "$1" 2>/dev/null | grep -qE "hfsc|cake|htb" && \
        nft -t list table inet dscptag >/dev/null 2>&1
}

# Validate a single nftables file
# Args: $1=file_path, $2=file_type, $3=tmp_file
# Returns: 0 on success, 1 on failure
validate_nft_file() {
    local file_path="$1" file_type="$2" tmp_file="$3"
    
    echo "Validating $file_type ($file_path):" >> "$tmp_file"
    
    if [ ! -s "$file_path" ]; then
        echo "  - File is empty or does not exist. Skipping." >> "$tmp_file"
        return 0
    fi
    
    case "$file_type" in
        "full table rules")
            if nft --check --file "$file_path" >> "$tmp_file" 2>&1; then
                echo "  - Syntax check PASSED." >> "$tmp_file"
                return 0
            else
                echo "  - Syntax check FAILED." >> "$tmp_file"
                return 1
            fi
            ;;
        "inline rules")
            validate_inline_rules "$file_path" "$tmp_file"
            ;;
        *)
            echo "  - ERROR: Unknown file type '$file_type'." >> "$tmp_file"
            return 1
            ;;
    esac
}

# Validate inline rules with context wrapping
# Args: $1=file_path, $2=tmp_file
validate_inline_rules() {
    local file_path="$1" tmp_file="$2"
    local check_file="/tmp/qosmate_inline_init_check.nft"
    
    echo "  - Checking for forbidden keywords (table, chain, hook, priority)..." >> "$tmp_file"
    
    if grep -Eq '^[[:space:]]*(table|chain|type|hook|priority)[[:space:]]+' "$file_path"; then
        echo "  - Keyword check FAILED. Forbidden keyword found." >> "$tmp_file"
        return 1
    fi
    
    printf '%s\n%s\n' "  - Keyword check PASSED." \
        "  - Checking NFT syntax (within context)..." >> "$tmp_file"
    
    # Create temporary file with context
    {
        printf '%s\n\t%s\n' "table inet __qosmate_validation_ctx {" \
            "chain __dscptag_init_ctx {"
        cat "$file_path"
        printf '\n\t%s\n%s\n' "}" "}"
    } > "$check_file"
    
    if nft --check --file "$check_file" 2>>"$tmp_file"; then
        echo "  - Syntax check PASSED." >> "$tmp_file"
        rm -f "$check_file"
        return 0
    else
        echo "  - Syntax check FAILED." >> "$tmp_file"
        rm -f "$check_file"
        return 1
    fi
}

validate_custom_rules() {
    local tmp_file="/tmp/qosmate_custom_rules_validation.txt"
    local fail=0
    
    # Clear previous validation results
    true > "$tmp_file"
    
    # Delete existing table before validation to avoid conflicts with existing sets/meters
    echo "Deleting existing qosmate_custom table before validation..." >> "$tmp_file"
    nft destroy table inet qosmate_custom
    
    # Validate both rule types
    if ! validate_nft_file "$QOSMATE_CUSTOM_RULES_FILE" "full table rules" "$tmp_file"; then
        fail=$((fail + 1))
    fi
    
    if ! validate_nft_file "$QOSMATE_INLINE_RULES_FILE" "inline rules" "$tmp_file"; then
        fail=$((fail + 1))
    fi
    
    # Report final result
    if [ $fail -eq 0 ]; then
        printf '\n%s\n' "Overall validation: PASSED" >> "$tmp_file"
        return 0
    else
        printf '\n%s\n' "Overall validation: FAILED" >> "$tmp_file"
        return 1
    fi
}

# Detect package manager (opkg or apk)
detect_package_manager() {
    local has_apk=0 has_opkg=0
    
    check_util apk && has_apk=1
    check_util opkg && has_opkg=1
    
    if [ $has_apk -eq 1 ] && [ $has_opkg -eq 1 ]; then
        echo "conflict"
    elif [ $has_apk -eq 1 ]; then
        echo "apk"
    elif [ $has_opkg -eq 1 ]; then
        echo "opkg"
    else 
        echo "unknown"
    fi
}

# Check for valid package manager
check_pkg_manager() {
    case "$1" in
        apk|opkg)
            return 0
            ;;
        conflict)
            error_out "Multiple package managers detected (apk and opkg). Package management disabled."
            return 1
            ;;
        *)
            error_out "No supported package manager found."
            return 1
            ;;
    esac
}

# Check if a package is installed
check_package() {
    local pkg="$1"
    
    case "$pkg_manager" in
        "apk")
            apk list -I "$pkg" 2>/dev/null | grep -q "^$pkg-[0-9]"
            return $?
            ;;
        "opkg")
            opkg list-installed | grep -q "^$pkg "
            return $?
            ;;
        *)
            return 1
            ;;
    esac
}

install_packages() {
    # Flag to indicate if opkg update is needed    
    local need_update=0

    check_pkg_manager "$pkg_manager" || return 1

    # Check if any packages are missing
    for pkg in $REQUIRED_PACKAGES; do
        if ! check_package "$pkg"; then
            print_msg "$pkg is not installed."
            need_update=1
            break
        fi
    done

    # Run update if at least one package is missing
    if [ "$need_update" -eq 1 ]; then
        print_msg "Updating package list..."
        case "$pkg_manager" in
            "apk")
                apk update
                ;;
            "opkg")
                opkg update
                ;;
        esac

        # Install missing packages
        for pkg in $REQUIRED_PACKAGES; do
            if ! check_package "$pkg"; then
                log_msg "Installing $pkg..."
                case "$pkg_manager" in
                    "apk")
                        apk add "$pkg" || {
                            error_out "Failed to install $pkg."
                            return 1  # Abort if the installation fails
                        }
                        ;;
                    "opkg")
                        opkg install "$pkg" || {
                            error_out "Failed to install $pkg."
                            return 1  # Abort if the installation fails
                        }
                        ;;
                esac
            fi
        done
    fi
}

check_package_status() {
    local missing_packages=""
    
    for pkg in $REQUIRED_PACKAGES; do
        if ! check_package "$pkg"; then
            missing_packages="$missing_packages $pkg"
        fi
    done
    
    if [ -n "$missing_packages" ]; then
        error_out "Missing packages:$missing_packages"
        return 1
    fi
    return 0
}

create_hotplug_script() {
    [ -s "$QOSMATE_HOTPLUG_SCRIPT" ] && { log_msg "Hotplug script already exists."; return 0; }
    log_msg "" "Creating the hotplug script."
    cat > "$QOSMATE_HOTPLUG_SCRIPT" << 'EOF'
#!/bin/sh

[ -n "$DEVICE" ] || exit 0
if [ "$ACTION" = ifup ]; then
    . /lib/functions.sh
    config_load qosmate || {
        logger -t qosmate -p user.err "Failed to load config."
        exit 1
    }
    config_get qosmate_enabled global enabled
    if [ "$qosmate_enabled" = "1" ]; then
        logger -t qosmate "Reloading qosmate.sh due to $ACTION of $INTERFACE ($DEVICE)"
        /etc/init.d/qosmate enable
        /etc/init.d/qosmate restart
    else
        logger -t qosmate "qosmate is disabled in the configuration. Not executing the script."
    fi
fi
EOF
}

manage_custom_rules_file() {
    local action="$1"
    
    case "$action" in
        create)
            # Ensure the directory exists
            try_mkdir -p "${QOSMATE_D}"
            
            # Create the files if they don't exist
            [ ! -f "$QOSMATE_CUSTOM_RULES_FILE" ] && touch "$QOSMATE_CUSTOM_RULES_FILE"
            [ ! -f "$QOSMATE_INLINE_RULES_FILE" ] && touch "$QOSMATE_INLINE_RULES_FILE"

            ;;
        delete)
            # Not used at the moment...
            rm -f "$QOSMATE_CUSTOM_RULES_FILE"
            rm -f "$QOSMATE_INLINE_RULES_FILE"
            ;;
    esac
}

start_service() {
    # handle first run after installation or update from older versions
    if [ "$VERSION" = dev ]; then
        log_msg "" "Completing the upgrade of the update mechanism..."
        update -f || return 1
        # shellcheck disable=SC1090
        . "${QOSMATE_SERVICE_PATH}" # source updated init script 
    fi

    # Check files and re-fetch them if any are missing
    for component in $QOSMATE_COMPONENTS; do
        check_files_integrity "$component"
        rv=$?
        case $rv in
            0) ;; # integrity OK
            1) return 1 ;;
            2|3) # missing files
                local fix_upd_channel='' fix_version='' force_version=''
                case "$component" in
                    BACKEND) fix_version="$VERSION" fix_upd_channel="$UPD_CHANNEL" ;;
                    FRONTEND) get_component_spec fix_version fix_upd_channel FRONTEND local 2>/dev/null
                esac
                case "$fix_version" in
                    dev|''|"1.1.0"|"v1.1.0") ;;
                    *) force_version="-W $fix_version"
                esac
                : "${fix_upd_channel:=release}"
                if ! update -U "$fix_upd_channel" $force_version -c "$component"; then
                    log_msg -warn "Failed to fix qosmate installation automatically."
                    [ $rv = 2 ] && {
                        log_msg "Either connect to the Internet and run '/etc/init.d/qosmate start' to have missing files automatically fetched," \
                        "or manually download and save them in designated paths."
                        return 1
                    }
                    # Do not fail on missing reg files
                fi ;;
            4) ;; # non-matching md5sums
        esac
    done

    install_packages

    create_hotplug_script

    load_and_fix_config || return 1

    # Enable the global option
    uci set qosmate.global.enabled='1'
    uci commit qosmate
    export global_enabled=1

    # Create custom rules file if it doesn't exist
    manage_custom_rules_file create
    nft destroy table inet qosmate_custom
    nft -f "$QOSMATE_CUSTOM_RULES_FILE"

    preserve_config_files # Add config files to sysupgrade.conf

    try_mkdir -p "$QOSMATE_RUN_DIR"

    # Save the current WAN interface to a temporary file
    printf '%s\n' "$WAN" > /tmp/qosmate_wan

    /bin/sh "$QOSMATE_MAIN_SCRIPT" || { error_out "Failed to start qosmate."; return 1; }

    /etc/init.d/firewall reload &&
    enabled || /etc/init.d/qosmate enable || return 1

    # Start autorate service if enabled (separate procd-managed service)
    if [ "$AUTORATE_ENABLED" = "1" ] && [ -f /etc/init.d/qosmate-autorate ]; then
        log_msg "" "Starting autorate service..."
        /etc/init.d/qosmate-autorate enable 2>/dev/null
        /etc/init.d/qosmate-autorate start
    fi

    log_msg "Service started"
}

stop_service() {
    # Read the old WAN interface from the temporary file
    OLD_WAN=$(cat /tmp/qosmate_wan 2>/dev/null)
    if [ -z "$OLD_WAN" ]; then
        # If the temporary file doesn't exist, fall back to WAN from config
        load_and_fix_config
        OLD_WAN="$WAN"
    fi

    print_msg "Stopping service qosmate..."
    
    # Stop autorate service (separate procd-managed service)
    /etc/init.d/qosmate-autorate stop 2>/dev/null

    # Only disable if not in shutdown and not in restart
    if [ "$DISABLE_ON_STOP" != "0" ] && [ -z "$FROM_SHUTDOWN" ]; then
        # Keep autorate enable-state in sync with qosmate disable-state.
        /etc/init.d/qosmate-autorate disable 2>/dev/null
        disable
        uci set qosmate.global.enabled='0'
        uci commit qosmate
    fi

    # Remove custom rules table
    nft destroy table inet qosmate_custom

    ## Delete files
    rm -f "$QOSMATE_HOTPLUG_SCRIPT"
    rm -f /usr/share/nftables.d/ruleset-post/dscptag.nft

    ## Delete the old qdiscs and IFB associated with the old WAN interface
    tc qdisc del dev "$OLD_WAN" root > /dev/null 2>&1
    tc qdisc del dev ifb-"$OLD_WAN" root > /dev/null 2>&1
    tc qdisc del dev "$OLD_WAN" ingress > /dev/null 2>&1

    # Remove IFB interface
    ip link del ifb-"$OLD_WAN" 2>/dev/null

    nft destroy table inet dscptag

    # Remove temporary/runtime files
    rm -f /tmp/qosmate_wan
    rm -rf "$QOSMATE_RUN_DIR"

    print_msg "Reloading network service..."
    /etc/init.d/network reload
    /etc/init.d/firewall reload
    log_msg "Service stopped"
}

shutdown() {
    # Set variable for shutdown
    FROM_SHUTDOWN=1
    # Call stop_service
    stop_service
}

status_service() {
    load_and_fix_config -nofix

    print_msg "==== qosmate Status ===="
    
    # Check if autostart is enabled
    if enabled; then
        print_msg "qosmate autostart is enabled."
    else
        print_msg "qosmate autostart is not enabled."
    fi

    # Check if the service is enabled in UCI config
    local config_enabled=true not_managing=
    if [ "$global_enabled" != 1 ]; then
        config_enabled=false
        not_managing=", but qosmate is not managing it"
    fi
    print_msg "qosmate global:enabled is $config_enabled."

    local dir dev qdisc qdiscs active_qdisc \
        IFB="ifb-$WAN"

    # Check and report if traffic shaping is active
    for dir in egress ingress; do
        case "$dir" in
            egress) dev="$WAN" ;;
            ingress) dev="$IFB" ;;
        esac
        qdiscs="$(tc qdisc show dev "$dev" 2>/dev/null | sed -n 's/^qdisc\s\s*\([^ \t]*\).* root .*/\1/p')"
        qdiscs="${qdiscs//$'\n'/ }"
        active_qdisc=''
        for qdisc in cake cake_mq hfsc htb; do
            case "${qdiscs}" in "${qdisc}"|"${qdisc} "*|*" ${qdisc}"|*" ${qdisc} "*)
                active_qdisc="$qdisc"
            esac
        done

        case "$active_qdisc" in
            cake|cake_mq) qdisc_print=CAKE ;;
            hfsc) qdisc_print=HFSC ;;
            htb) qdisc_print=HTB ;;
        esac

        if [ -n "$active_qdisc" ]; then
            print_msg "Traffic shaping ($qdisc_print) is active on the $dir interface ($dev)$not_managing."
        else
            print_msg "No traffic shaping is active on the $dir interface ($dev)."
        fi
    done

    # Show summary of current settings
    print_msg "" "==== Current Settings ====" \
        "Upload rate: $UPRATE kbps" \
        "Download rate: $DOWNRATE kbps" \
        "Game traffic upload: $GAMEUP kbps" \
        "Game traffic download: $GAMEDOWN kbps"
    if [ "$ROOT_QDISC" = "cake" ]; then
        print_msg "Queue discipline: CAKE (Root qdisc)"
        local active_cake_type=''
        read -r active_cake_type < /tmp/qosmate/cake_type 2>/dev/null
        if [ "$active_cake_type" = "cake_mq" ]; then
            print_msg "Multi-Queue CAKE: active (cake_mq)"
        elif [ "$USE_MQ" = "1" ]; then
            if [ "$active_cake_type" = "cake" ]; then
                print_msg "Multi-Queue CAKE: not available, using standard cake"
            else
                print_msg "Multi-Queue CAKE: enabled in config (service not running)"
            fi
        else
            print_msg "Multi-Queue CAKE: off"
        fi
    elif [ "$ROOT_QDISC" = "htb" ]; then
        print_msg "Queue discipline: HTB (Root qdisc)"
    else
        print_msg "Queue discipline: $gameqdisc (for game traffic in HFSC)"
        if [ "$ROOT_QDISC" = "hybrid" ] && [ "$USE_MQ" = "1" ]; then
            print_msg "Multi-Queue CAKE: not supported in hybrid mode"
        fi
    fi

    if [ "$AUTORATE_ENABLED" = "1" ]; then
        if [ -f /etc/init.d/qosmate-autorate ] && /etc/init.d/qosmate-autorate running; then
            print_msg "Autorate: active"
        else
            print_msg "Autorate: enabled in config but not running"
        fi
    else
        print_msg "Autorate: off"
    fi

    # Display QoSmate version information
    print_msg "" "==== Version Information ===="
    check_version

    # Display system information
    print_msg "" "==== System Information ===="
    ubus call system board

    # Display health check information
    print_msg "" "==== Health Check ===="
    health_check

    # Check for flow offloading settings (incompatible with qosmate)
    print_msg "" "==== Flow Offloading Check ===="
    local flow_offloading_enabled=0
    local flow_offloading_hw_enabled=0
    
    # Check software flow offloading in firewall defaults
    if [ "$(uci -q get firewall.@defaults[0].flow_offloading)" = "1" ]; then
        flow_offloading_enabled=1
        print_msg "WARNING: Software flow offloading is enabled."
    fi
    
    # Check hardware flow offloading in firewall defaults
    if [ "$(uci -q get firewall.@defaults[0].flow_offloading_hw)" = "1" ]; then
        flow_offloading_hw_enabled=1
        print_msg "WARNING: Hardware flow offloading is enabled."
    fi
    
    if [ $flow_offloading_enabled -eq 1 ] || [ $flow_offloading_hw_enabled -eq 1 ]; then
        print_msg "CRITICAL: Flow offloading is incompatible with qosmate and may cause issues!"
        print_msg "To disable flow offloading, run:"
        if [ $flow_offloading_enabled -eq 1 ]; then
            print_msg "  uci set firewall.@defaults[0].flow_offloading='0'"
        fi
        if [ $flow_offloading_hw_enabled -eq 1 ]; then
            print_msg "  uci set firewall.@defaults[0].flow_offloading_hw='0'"
        fi
        print_msg "  uci commit firewall"
        print_msg "  /etc/init.d/firewall restart"
    else
        print_msg "Flow offloading is disabled (compatible with qosmate)."
    fi

    # Display WAN interface information
    print_msg "" "==== WAN Interface Information ===="
    ifstatus wan | grep -e device

    # Display QoSmate configuration
    print_msg "" "==== QoSmate Configuration ===="
    cat "$QOSMATE_CFG_FILE"

    print_msg "==== Package Status ===="

    if check_pkg_manager "$pkg_manager" && check_package_status; then
        print_msg "All required packages are installed."
    else
        print_msg "Some required packages are missing. QoSmate may not function correctly."
    fi

    print_msg "" "==== Detailed Technical Information ====" \
        "Traffic Control (tc) Queues:"
    tc -s qdisc

    print_msg "" "==== Nftables Ruleset (dscptag) ===="
    nft list ruleset | grep 'chain dscptag' -A 100


    print_msg "" "==== Custom Rules Table Status ===="
    if nft list table inet qosmate_custom &>/dev/null; then
        print_msg "Custom rules table (qosmate_custom) is active." \
            "Current custom rules:"
        nft list table inet qosmate_custom
    else
        print_msg "Custom rules table (qosmate_custom) is not active or doesn't exist."
    fi

    print_msg "" "==== Inline Rules Status ===="
    if [ -s "$QOSMATE_INLINE_RULES_FILE" ]; then
        print_msg "Inline rules are configured:"
        cat "$QOSMATE_INLINE_RULES_FILE"
        printf '\n'
    else
        print_msg "No inline rules configured."
    fi
}

restart() {
    DISABLE_ON_STOP=0 stop_service
    sleep 1 # Ensure all processes have been properly terminated
    start_service
}

reload_service() {
    restart
}

# 1 - speedtest command
# 2 - Upload|Download
# I/O via STDIN/STDOUT
parse_speedtest_output() {
    local speedtest_cmd="$1" direction="$2"
    case "$speedtest_cmd" in
        speedtest-go*)
            grep "${direction}:" | grep -oE '[0-9]+\.[0-9]+' | head -n1
            ;;
        "speedtest --simple"*)
            # match to (Upload:|Download:).*, print field 2. if no match, return 1
            awk "BEGIN{rv=1} \$0~/$direction:/ {print \$2; rv=0; exit} END{exit rv}"
            ;;
        *)
            error_out "Unexpected speedtest command '$speedtest_cmd'."
            false
            ;;
    esac || {
        error_out "Failed to get the $direction speed."
        echo "0"
        return 1
    }
    :
}

auto_setup() {
    try_auto_setup "$@" && return 0
    error_out "auto-setup failed."
    uci revert qosmate 2>/dev/null
    return 1
}

# for non-interactive setup, call with '-n [gaming_ip_address]'
try_auto_setup() {
    # shellcheck disable=SC2329
    is_ip_configured() {
        local ip ip_type section_id="$1" gaming_ip="$2"
        for ip_type in src_ip dest_ip; do
            config_get ip "$section_id" "$ip_type"
            [ "$ip" = "$gaming_ip" ] && { ip_configured=1; break; }
        done
    }

    local L3_DEVICE WAN_INTERFACE FINAL_INTERFACE SPEEDTEST_CMD FREE_SPACE SPEED_RESULT DOWNLOAD_SPEED UPLOAD_SPEED
    local gaming_ip='' noninteractive='' speed_choice response speedtest_req direction

    if [ "$1" = "-n" ]; then
        gaming_ip="$2"
        noninteractive=1
    else
        noninteractive=
    fi
    [ -z "$noninteractive" ] && print_msg "Starting qosmate auto-setup..."

    # Detect WAN interface
    WAN_INTERFACE=$(ifstatus wan | grep -e '"device"' | cut -d'"' -f4)
    L3_DEVICE=$(ifstatus wan | grep -e '"l3_device"' | cut -d'"' -f4)

    if [ -z "$WAN_INTERFACE" ] && [ -z "$L3_DEVICE" ]; then
        error_out "Unable to detect WAN interface. Please set it manually in the configuration."
        return 1
    fi

    FINAL_INTERFACE=${L3_DEVICE:-$WAN_INTERFACE}
    print_msg "Detected WAN interface: $FINAL_INTERFACE"

    # Stop qosmate if it's running
    if is_qosmate_active "$FINAL_INTERFACE"; then
        print_msg "Stopping qosmate for accurate speed test results..."
        stop_service
        sleep 5  # Give some time for the network to stabilize
    fi

    if [ -n "$noninteractive" ]; then
        speedtest_req=1
    else
        while :; do
            print_msg "Do you want to run a speed test or enter speeds manually? [test/manual]"
            read -r speed_choice
            case "$speed_choice" in
                *[A-Z]*) speed_choice="$(printf %s "$speed_choice" | tr 'A-Z' 'a-z')"
            esac

            case "$speed_choice" in
                test|manual) break
            esac
            print_msg "Invalid input '$speed_choice'. Please enter 'test' or 'manual'."
        done

        if [[ "$speed_choice" = manual ]]; then
            for direction in download upload; do
                while :; do
                    print_msg "Please enter your $direction speed in Mbit/s:"
                    read -r response
                    case "$response" in
                        ''|*[!0-9.]*|*.*.*)
                            # do not allow empty string or irrelevant characters or 2x '.'
                            print_msg "Invalid input '$response'. Please try again."
                            continue
                            ;;
                        *[0-9]*)
                            break
                            ;;
                        *)
                            # do not allow input without digits
                            print_msg "Invalid input '$response'. Please try again."
                            continue
                            ;;
                    esac
                done
                case "$direction" in
                    download) DOWNLOAD_SPEED="$response" ;;
                    upload) UPLOAD_SPEED="$response" ;;
                esac
            done
            speedtest_req=
        else
            print_msg "This will run a speed test to configure qosmate. Do you want to continue? [y/N]"
            read -r response
            if [[ ! "$response" =~ ^[Yy]$ ]]; then
                print_msg "Auto-setup cancelled."
                return 0
            fi
            speedtest_req=1
        fi
    fi

    if [ -n "$speedtest_req" ]; then
        # Check for speedtest-go first
        if check_util speedtest-go; then
            print_msg "speedtest-go is already installed. Using it for the speed test."
            SPEEDTEST_CMD="speedtest-go"
        else
            print_msg "speedtest-go is not found. Checking for python3-speedtest-cli..."
            if check_util speedtest; then
                print_msg "python3-speedtest-cli is already installed. Using it for the speed test."
                SPEEDTEST_CMD="speedtest --simple"
            else
                print_msg "Neither speedtest-go nor python3-speedtest-cli is installed. Attempting to install speedtest-go..."
                check_pkg_manager "$pkg_manager" || return 1
                
                # Check for sufficient space (adjust the required space as needed)
                FREE_SPACE=$(df /overlay | awk 'NR==2 {print $4}')
                if [ "$FREE_SPACE" -lt 15360 ]; then  # Assuming 15MB for speedtest-go
                    print_msg "Not enough space for speedtest-go. Attempting to install python3-speedtest-cli instead..."
                else
                    case "$pkg_manager" in
                        "apk")
                            apk update && apk add speedtest-go
                            ;;
                        "opkg")
                            opkg update && opkg install speedtest-go
                            ;;
                    esac
                    if [ $? -eq 0 ]; then
                        SPEEDTEST_CMD="speedtest-go"
                    else
                        error_out "Failed to install speedtest-go. Attempting to install python3-speedtest-cli instead..."
                    fi
                fi

                # If speedtest-go installation failed or there wasn't enough space, try python3-speedtest-cli
                if [ -z "$SPEEDTEST_CMD" ]; then
                    if [ "$FREE_SPACE" -lt 1024 ]; then  # 1MB for python3-speedtest-cli
                        error_out "Error: Not enough free space to install any speedtest tool." \
                            "Auto-setup cannot continue. Please free up some space and try again."
                        return 1
                    fi
                    case "$pkg_manager" in
                        "apk")
                            apk update && apk add python3-speedtest-cli
                            ;;
                        "opkg")
                            opkg update && opkg install python3-speedtest-cli python3-speedtest-cli-src
                            ;;
                    esac
                    if [ $? -eq 0 ]; then
                        SPEEDTEST_CMD="speedtest --simple"
                    else
                        error_out "Failed to install python3-speedtest-cli. Auto-setup cannot continue."
                        return 1
                    fi
                fi
            fi
        fi

        print_msg "Running speed test... This may take a few minutes."
        SPEED_RESULT=$($SPEEDTEST_CMD)

        DOWNLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Download) &&
        UPLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Upload) ||
            return 1

        print_msg "Speed test results:" \
            "Download speed: $DOWNLOAD_SPEED Mbit/s" \
            "Upload speed: $UPLOAD_SPEED Mbit/s"
    fi

    # Convert speeds to kbps and apply 90% rule
    DOWNRATE=$(awk -v speed="$DOWNLOAD_SPEED" 'BEGIN {print int(speed * 1000 * 0.9)}')
    UPRATE=$(awk -v speed="$UPLOAD_SPEED" 'BEGIN {print int(speed * 1000 * 0.9)}')

    print_msg "QoS configuration:" \
        "DOWNRATE: $DOWNRATE kbps (90% of measured download speed)" \
        "UPRATE: $UPRATE kbps (90% of measured upload speed)"

    [ -f "$QOSMATE_CFG_FILE" ] || {
        error_out "config file '$QOSMATE_CFG_FILE' not found."
        return 1
    }

    config_load qosmate &&
    uci set qosmate.settings.WAN="$FINAL_INTERFACE" &&
    uci set qosmate.settings.DOWNRATE="$DOWNRATE" &&
    uci set qosmate.settings.UPRATE="$UPRATE" || return 1

    # Section for gaming device IP
    if [ -z "$noninteractive" ]; then
        print_msg "Would you like to add a gaming device IP for prioritization? [y/N]"
        read -r response
        if [[ "$response" =~ ^[Yy]$ ]]; then
            print_msg "Please enter the IP address of your gaming device:"
            read -r gaming_ip
        fi
    fi

    if [ -n "$gaming_ip" ]; then
        # Validate IP address format
        if [[ $gaming_ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
            # Check if rules for this IP already exist
            local ip_configured=
            config_foreach is_ip_configured rule "$gaming_ip"
            # $ip_configured is set in is_ip_configured()
            if [ -n "$ip_configured" ]; then
                print_msg "Rules for IP $gaming_ip already exist. Skipping addition of new rules."
                gaming_ip=
            fi
        else
            print_msg "Invalid IP address format. No gaming device rules added."
            gaming_ip=
        fi
    fi

    if [ -n "$gaming_ip" ]; then
        local direction ip_dir port_dir
        for direction in Inbound Outbound; do
            case "$direction" in
                Inbound) ip_dir=dest port_dir=src ;;
                Outbound) ip_dir=src port_dir=dest ;;
            esac
            uci add qosmate rule 1>/dev/null &&
            uci set "qosmate.@rule[-1].name=Game_Console_${direction}" &&
            uci set "qosmate.@rule[-1].proto=udp" &&
            uci set "qosmate.@rule[-1].${ip_dir}_ip=${gaming_ip}" &&
            uci add_list "qosmate.@rule[-1].${port_dir}_port=!=80" &&
            uci add_list "qosmate.@rule[-1].${port_dir}_port=!=443" &&
            uci set "qosmate.@rule[-1].class=cs5" &&
            uci set "qosmate.@rule[-1].counter=1" ||
                return 1
        done
        print_msg "Gaming device rules added for IP: $gaming_ip"
    else
        print_msg "No gaming device IP added."
    fi

    uci commit qosmate || return 1

    print_msg "Configuration updated. New settings:"
    grep -E "option (WAN|DOWNRATE|UPRATE)" "$QOSMATE_CFG_FILE"


    print_msg "Auto-setup complete. qosmate has been configured with detected settings." \
        "To apply these changes, please restart qosmate by running: $QOSMATE_SERVICE_PATH restart"
    :
}

auto_setup_noninteractive() {
    local output_file="/tmp/qosmate_auto_setup_output.txt" auto_setup_rv
    {
        echo "Starting qosmate non-interactive auto-setup..."
        auto_setup -n "$1" 2>&1
    } > "$output_file"
    auto_setup_rv=$?
    echo "$output_file"
    return $auto_setup_rv
}

health_check() {
    # Check if QoSmate is properly configured and running
    local status="" errors=0 service_enabled=0 config_status=''

    # Check the config
    if load_and_fix_config -nofix 1>/dev/null; then
        config_status=ok
    else
        config_status=failed # *** NOTE THE CHANGE FROM 'missing' TO 'failed' ***
        errors=$((errors + 1))
    fi

    # Check global:enabled and if the service is enabled
    enabled && service_enabled=1

    case "${service_enabled}${global_enabled}" in
        11) status="${status}service:enabled;" ;;
        00) status="${status}service:disabled;"; errors=$((errors + 1)) ;;
        *) status="${status}service:invalid_state;"; errors=$((errors + 1)) ;;
    esac

    # Check nftables configuration
    if nft list table inet dscptag >/dev/null 2>&1; then
        status="${status}nft:ok;"
    else
        status="${status}nft:failed;"
        errors=$((errors + 1))
    fi

    # Check tc configuration on WAN interface
    if [ -n "$WAN" ] && tc qdisc show dev "$WAN" 2>/dev/null | grep -E -q "hfsc|cake|htb"; then
        status="${status}tc:ok;"
    else
        status="${status}tc:failed;"
        errors=$((errors + 1))
    fi

    status="${status}config:${config_status};"

    # Check required packages
    local missing_packages=""
    for pkg in $REQUIRED_PACKAGES; do
        if ! check_package "$pkg"; then
            missing_packages="${missing_packages}${pkg} "
        fi
    done

    if [ -z "$missing_packages" ]; then
        status="${status}packages:ok;"
    else
        status="${status}packages:missing:${missing_packages};"
        errors=$((errors + 1))
    fi

    # Check files existence and md5sums
    for component in ${QOSMATE_COMPONENTS}; do
        if check_files_integrity "$component"; then
            status="${status}${component}_integrity:ok;"
        else
            status="${status}${component}_integrity:failed;"
            errors=$((errors + 1))
        fi
    done

    # Output status
    echo "status=$status;errors=$errors"
    
    # Return code depending on errors
    [ $errors -eq 0 ] || return 1
    return 0
}

pkg_manager="$(detect_package_manager)" # global variable


### Process command-line args

# if called directrly via /bin/sh with one of the keywords, set $action to the keyword
case "$1" in
    update|print_file_list|post_update_1|post_update_2) action="$1"; shift
esac

case "$action" in
    update|print_file_list|post_update_1|post_update_2) "$action" "$@"; exit $? ;;
esac

:

service_triggers() {
	procd_add_reload_trigger "qosmate"
}
