#!/bin/sh

. /lib/functions.sh
. /usr/share/libubox/jshn.sh
. /lib/delos-functions.sh

PACKAGE="station_quota"
VERSION="v2.0.0"

# the lock is meant for ebtables, and other scripts are also
# using ebtables. Needs clean up, maybe move to functions.sh, TBD?
LOCKFILE=/tmp/ebtables.lock

EVENTFILE=/tmp/station_quota_events.txt
CONFIG_SYNC_RETRIES=3
CONFIG_SYNC_DUPLICATE_TIME=2
CONFIG_SYNC_KEEP_ENTRIES=100

# ubus timer namespace prefix
TIMER_PREFIX=quota-

##### logging

function logger() {
    if [ $CFG_LOGGING -eq 1 ]; then
        command logger -t "$PACKAGE" "$@"
    fi
}

##### ebtables handling

ebt_create_chains() {
    ebtables -N "i-${PACKAGE}" -P RETURN 2>/dev/null || return 0
    ebtables -N "o-${PACKAGE}" -P RETURN 2>/dev/null || return 0
    ebtables -A INPUT   -i ath+ -j "i-${PACKAGE}"
    ebtables -A FORWARD -i ath+ -j "i-${PACKAGE}"
    ebtables -A FORWARD -o ath+ -j "o-${PACKAGE}"
    ebtables -A OUTPUT  -o ath+ -j "o-${PACKAGE}"
}

ebt_delete_chains() {
    ebtables -D INPUT   -i ath+ -j "i-${PACKAGE}" 2>/dev/null
    ebtables -D FORWARD -i ath+ -j "i-${PACKAGE}" 2>/dev/null
    ebtables -D FORWARD -o ath+ -j "o-${PACKAGE}" 2>/dev/null
    ebtables -D OUTPUT  -o ath+ -j "o-${PACKAGE}" 2>/dev/null
    ebtables -X "i-${PACKAGE}" 2>/dev/null
    ebtables -X "o-${PACKAGE}" 2>/dev/null
}

ebt_add_macs_to_blacklist() {
    local macs="$1"
    
    local mac
    for mac in $macs; do
        logger "Adding $mac to blacklist"
        ebtables -D "i-${PACKAGE}" -s "$mac" -j DROP 2>/dev/null
        ebtables -A "i-${PACKAGE}" -s "$mac" -j DROP
        ebtables -D "o-${PACKAGE}" -d "$mac" -j DROP 2>/dev/null
        ebtables -A "o-${PACKAGE}" -d "$mac" -j DROP
    done
}

ebt_remove_macs_from_blacklist() {
    local macs="$1"
    
    local mac
    for mac in $macs; do
        logger "Removing $mac from blacklist"
        ebtables -D "i-${PACKAGE}" -s "$mac" -j DROP 2>/dev/null
        ebtables -D "o-${PACKAGE}" -d "$mac" -j DROP 2>/dev/null
    done
}

##### timer handling

timer_to_mac() {
    local timer_id="$1"
    
    echo "${timer_id#${TIMER_PREFIX}}"
}

mac_to_timer() {
    local mac="$1"
    
    echo "${TIMER_PREFIX}${mac}"
}

# High-level code should not call this directly. Call quota_operation instead in order to
# ensure proper blacklist handling.
timer_operation() {
    local operation="$1"
    local mac="$2"
    local arg1="$3"
    local arg2="$4"
    local arg3="$5"

    local timer_id=$(mac_to_timer "$mac")
    
    # logger "Timer operation '$operation' for '$mac' (arg1='$arg1', arg2='$arg2', arg3='$arg3')"
    
    case "$operation" in
        list)
            echo $(ubus call timer "$operation" "{\"id\":\"${timer_id}\"}" 2>/dev/null | jsonfilter -e "@.ids[@~\"^${TIMER_PREFIX}\"]")
            ;;
        query)
            echo $(ubus call timer "$operation" "{\"id\":\"${timer_id}\"}" 2>/dev/null)
            ;;
        getvar)
            local var="$arg1"
            echo $(ubus call timer query "{\"id\":\"${timer_id}\"}" 2>/dev/null | jsonfilter -e '$.'"$var" 2>/dev/null)
            ;;
        pause|resume|remove)
            ubus call timer "$operation" "{\"id\":\"${timer_id}\"}" 2>/dev/null
            ;;
        new)
            ubus call timer remove "{\"id\":\"${timer_id}\"}" 2>/dev/null

            local timeout="${arg1:-0}"
            local remaining="${arg2:-${timeout}}"
            local start=$(( $timeout - $remaining ))
            [ $start -lt 0 ] && start=0
            
            ubus call timer add "{\"id\":\"${timer_id}\",\"command\":\"${MYSELF}\",\"timeout\":${timeout},\"start\":${start}}"
            ;;
        *)
            logger "Unknown timer operation '$operation'"
            ;;
    esac
}

##### get configured quota

config_get_quota_cb() {
    local name="$1"
    local mac="$2"
    local day="$3"
    local option="$4"
    local result_var="$5"

    local entry_mac=$(normalize_mac $(config_get "$name" "station"))
    local entry_option_val=$(config_get "$name" "$option")
    local entry_days=$(config_get "$name" "daysofweek")

    case "$entry_days" in
        *${day}*)
            [ "$entry_mac" = "$mac" ] && append "$result_var" "$entry_option_val"
            ;;
    esac
}

config_get_quota() {
    local mac="$1"

    local result=""
    local day="$(date +%A)"

    config_foreach config_get_quota_cb "entry" "$mac" "$day" "quota" "result"
    
    echo "$result"
}

config_list_stations_cb() {
    local name="$1"
    local result_var="$2"

    local entry_mac=$(normalize_mac $(config_get "$name" "station"))
    local previous_macs=$(eval echo -n \$$result_var)

    if [ -z $(echo "$previous_macs" | grep "$entry_mac") ]; then
        append "$result_var" "$entry_mac"
    fi
}

config_list_stations() {
    local result=""

    config_foreach config_list_stations_cb "entry" "result"
    
    echo "$result"
}

##### WiFi helpers

wifi_get_connected_stations() {
    local result=""

    local devices
    json_load "$(ubus call iwinfo devices)"
    json_get_values devices devices
    
    local device
    for device in $devices; do
        local clients=""
        eval $(ubus call "hostapd.$device" get_clients 2>/dev/null | jsonfilter -e 'clients=$.clients' 2>/dev/null)
        logger "Connected stations @ $device: $clients"
        
        local client
        for client in $clients; do
            append result $(normalize_mac "$client")
        done
    done
    
    echo "$result"
}

wifi_is_station_connected() {
    local mac="$1"
    local stations=$(wifi_get_connected_stations)
    echo "$stations" | grep "$mac"
}

wifi_kick_station() {
    local mac="$1"
    
    local vap
    for vap in $(ubus call iwinfo devices | jsonfilter -e '$.devices[@~"^ath"]'); do
        iwpriv "$vap" kickmac "$mac"
    done
}

##### misc. helpers

normalize_mac() {
    local mac="$1"
    
    echo "$mac" | awk '{print toupper($0)}'
}

##### config sync

config_sync_send_event() {
    local event="$1"
    
    logger "Sending sync event '$event'"
    for I in $(seq 1 $CONFIG_SYNC_RETRIES); do
        ubus call configsync send_message "{\"service\":\"quota\",\"event\":\"${event}\"}"
    done
}

config_sync_send_station_event() {
    local event="$1"
    local mac="$2"
    
    local remaining=$(quota_operation getvar "$mac" remaining)
    if [ -n "$remaining" ]; then
        logger "Sending sync event '$event' for '$mac': remaining $remaining"
        for I in $(seq 1 $CONFIG_SYNC_RETRIES); do
            ubus call configsync send_message "{\"service\":\"quota\",\"event\":\"${event}\",\"mac\":\"${mac}\",\"remaining\":${remaining}}"
        done
    fi
}

config_sync_write_event() {
    echo "$(date +%s) '$1' '$2' '$3'" >> "$EVENTFILE"
    
    tail -n $CONFIG_SYNC_KEEP_ENTRIES "$EVENTFILE" > "$EVENTFILE".tmp
    mv -f "$EVENTFILE".tmp "$EVENTFILE"
}

config_sync_is_duplicate_event() {
    local difftime=""
    
    [ -f "$EVENTFILE" ] || return 1
    local line=$(cat "$EVENTFILE" | grep " '$1' '$2' '$3'\$" | tail -n 1)
    if [ -n "$line" ]; then
        local evtime=$(echo "$line" | sed -e 's/ .*//')
        local nowtime=$(date +%s)
        difftime=$(( $nowtime - $evtime ))
    fi
    
    if [ -n "$difftime" ] && [ $difftime -le $CONFIG_SYNC_DUPLICATE_TIME ]; then
        logger "Ignoring duplicate event"
        return 0
    else
        return 1
    fi
}

##### station quota program logic

update_blacklist_entry() {
    local mac="$1"
    
    local cfg_quota=$(config_get_quota "$mac")
    local state=$(quota_operation getvar "$mac" state)

    if [ -z "$cfg_quota" ]; then
        ebt_remove_macs_from_blacklist "$mac"
    elif [ -z "$state" ] || [ "$state" = "RUNNING" ]; then
        ebt_remove_macs_from_blacklist "$mac"
    else
        ebt_add_macs_to_blacklist "$mac"  # paused or expired timer
    fi
}

quota_operation() {
    local operation="$1"
    local mac="$2"
    local arg1="$3"
    local arg2="$4"
    local arg3="$5"

    # logger "Quota operation '$operation' for '$mac' (arg1='$arg1', arg2='$arg2', arg3='$arg3')"
    
    # operate timer
    case "$operation" in
        list|query|getvar)
            echo $(timer_operation "$operation" "$mac" "$arg1" "$arg2" "$arg3")
            ;;
        pause|resume|new|remove)
            timer_operation "$operation" "$mac" "$arg1" "$arg2" "$arg3"
            ;;
        updatebl|*)
            ;;
    esac

    # operate blacklist
    case "$operation" in
        list|query|getvar)
            ;;
        pause|resume|new|updatebl)
            update_blacklist_entry "$mac"
            ;;
        remove)
            ebt_remove_macs_from_blacklist "$mac"
            ;;
        *)
            logger "Unknown quota operation '$operation'"
            ;;
    esac
}

station_associated() {
    local mac="$1"
    local sync="${2:-1}"
    
    logger "Station $mac associated (sync=$sync)"
    local state=$(quota_operation getvar "$mac" state)
    if [ "$state" != "EXPIRED" ]; then
        if [ -z "$state" ]; then
            local cfg_quota=$(config_get_quota "$mac")
            if [ -n "$cfg_quota" ]; then
                logger "Starting new quota for $mac ($cfg_quota)"
                quota_operation new "$mac" $(dvl_time_to_mseconds "$cfg_quota")
            fi
        else
            logger "Resuming quota for $mac"
            quota_operation resume "$mac"
        fi
        [ $sync -eq 1 ] && config_sync_send_station_event assoc "$mac"
    fi
}

station_disassociated() {
    local mac="$1"
    local sync="${2:-1}"

    logger "Station $mac disassociated (sync=$sync)"
    local state=$(quota_operation getvar "$mac" state)
    if [ "$state" != "EXPIRED" ]; then
        if [ -n "$state" ]; then
            quota_operation pause "$mac"
            [ $sync -eq 1 ] && config_sync_send_station_event disassoc "$mac"
        fi
    fi
}

station_timer_expired() {
    local mac="$1"
    
    logger "Timer for station $mac expired"
    local state=$(quota_operation getvar "$mac" state)
    if [ -n "$state" ]; then
        quota_operation updatebl "$mac"
        wifi_kick_station "$mac"
    fi
}

station_updated_by_remote() {
    local mac="$1"
    local remaining="$2"

    logger "Quota for station $mac updated by remote (remaining $remaining)"

    local mseconds="$remaining"

    local cfg_quota=$(config_get_quota "$mac")
    if [ -n "$cfg_quota" ]; then
        mseconds=$(dvl_time_to_mseconds "$cfg_quota")
    fi

    [ $remaining -le 0 ] && remaining=1  # timer wouldn't get created with zero time remaining
    
    quota_operation new "$mac" "$mseconds" "$remaining"
}

midnight() {
    for timer in $(quota_operation list); do
        local mac=$(timer_to_mac "$timer")
        local cfg_quota=$(config_get_quota "$mac")
        local state=$(quota_operation getvar "$mac" state)

        if [ -n "$cfg_quota" ] && [ "$state" = "RUNNING" ]; then
            logger "Refreshing running quota for station $mac ($cfg_quota)"
            quota_operation new "$mac" $(dvl_time_to_mseconds "$cfg_quota")
        else
            logger "Removing stale quota for station $mac"
            quota_operation remove "$mac"
        fi
    done
}

request_all_quotas_from_remote() {
    logger "Requesting quota from remote devices"
    config_sync_send_event list
}

announce_all_quotas_to_remote() {
    for timer in $(quota_operation list); do
        local mac=$(timer_to_mac "$timer")
        local state=$(quota_operation getvar "$mac" state)
        local remaining=$(quota_operation getvar "$mac" remaining)
        
        if [ -n "$state" ]; then
            local event=""
            case "$state" in
                RUNNING)
                    event="assoc"
                    ;;
                PAUSED|EXPIRED)
                    event="disassoc"
                    ;;
            esac

            if [ -n "$event" ]; then            
                logger "Announcing station $mac ($event, remaining $remaining)"
                config_sync_send_station_event "$event" "$mac" "$remaining"
            fi
        fi
    done
}

update_quotas_for_connected_stations() {
    for station in $(wifi_get_connected_stations); do
        logger "Adding quota for connected station $mac"
        station_associated "$station" 1
    done
}

remove_all_station_quotas() {
    for timer in $(quota_operation list); do
        quota_operation remove $(timer_to_mac "$timer")
    done
}

reload_quota_config() {
    # remove quotas for stations which have no configured quota anymore
    for timer in $(quota_operation list); do
        local mac=$(timer_to_mac "$timer")
        local cfg_quota=$(config_get_quota "$mac")

        if [ -z "$cfg_quota" ]; then
            logger "Removing stale quota for station $mac"
            quota_operation remove "$mac"
        fi
    done

    # update quotas for configured stations
    for mac in $(config_list_stations); do
        local cfg_quota=$(config_get_quota "$mac")
        if [ -n "$cfg_quota" ]; then
            local cfg_mseconds=$(dvl_time_to_mseconds "$cfg_quota")
            local state=$(quota_operation getvar "$mac" state)
            local timeout=$(quota_operation getvar "$mac" timeout)
            
            if [ -z "$timeout" ] || [ "$timeout" -ne "$cfg_mseconds" ]; then
                # configured quota is new or different from timer timeout
                if [ "$state" = "RUNNING" ] || wifi_is_station_connected "$mac"; then
                    logger "Changing quota for connected station $mac ($cfg_quota)"
                    quota_operation new "$mac" "$cfg_mseconds"
                    config_sync_send_station_event assoc "$mac" "$cfg_mseconds"
                else
                    logger "Removing quota for disconnected station $mac ($cfg_quota)"
                    quota_operation remove "$mac"
                fi
            fi
        fi
    done
}

##### service handling

start_service() {
    ebt_create_chains
    crontab -l | grep -v "$MYSELF" | crontab -
    (crontab -l; echo "00 00 * * * $MYSELF --event midnight") | sort -u | crontab -
    
    > "$EVENTFILE"
    remove_all_station_quotas
    (sleep 120; "$MYSELF" -e request_list; sleep 10; "$MYSELF" -e update_connected) &
}

stop_service() {
    rm -f "$EVENTFILE"
    remove_all_station_quotas

    crontab -l | grep -v "$MYSELF" | crontab -
    ebt_delete_chains
}

##### option handling

get_program_options() {
    OPT_EVENT=""
    OPT_MAC=""
    OPT_REMAINING=""
    OPT_TIMER_ID=""
    
    if [ -n "$TIMER_ID" ]; then
        # called by timer
        OPT_EVENT="timer_expired"
        OPT_TIMER_ID="$TIMER_ID"
        OPT_MAC=$(normalize_mac $(timer_to_mac "$TIMER_ID"))
    else
        # called by script or cron
        while [ $# -gt 0 ]; do
            local key="$1"
            shift
            case "$key" in
                start|stop)
                    OPT_EVENT="$key"
                    ;;
                -e|--event)
                    OPT_EVENT="$1"
                    shift
                    ;;
                -m|--mac)
                    OPT_MAC=$(normalize_mac "$1")
                    shift
                    ;;
                -r|--remaining)
                    OPT_REMAINING="$1"
                    shift
                    ;;
                *)
                    logger "Unknown command line option '$key'"
                    ;;
            esac
        done
    fi
}

##### main

config_load "$PACKAGE"
config_get_bool CFG_PACKAGE_ENABLED global enabled 0
config_get_bool CFG_LOGGING global logging 0

MYSELF="$0"
RETURN_VALUE=0

lock "$LOCKFILE"

if [ $CFG_PACKAGE_ENABLED -eq 1 ]; then
    get_program_options "$@"

    logger "Event '$OPT_EVENT' for station '$OPT_MAC', remaining '$OPT_REMAINING'"
    
    case "$OPT_EVENT" in
        start)
            start_service
            ;;
        stop)
            stop_service
            ;;
        reload)
            initialized=$(ebtables -L | grep "$PACKAGE")
            if [ -z "$initialized" ]; then
                logger "Service $PACKAGE was not started, initializing"
                start_service
            fi
            reload_quota_config
            ;;
        assoc)
            station_associated "$OPT_MAC" 1
            ;;
        disassoc)
            station_disassociated "$OPT_MAC" 1
            ;;
        request_list)
            request_all_quotas_from_remote
            ;;
        midnight)
            midnight
            ;;
        timer_expired)
            station_timer_expired "$OPT_MAC"
            ;;
        update_connected)
            update_quotas_for_connected_stations
            ;;
        assoc_remote)
            if [ -n "$OPT_REMAINING" ] && ! config_sync_is_duplicate_event "$OPT_EVENT" "$OPT_MAC" "$OPT_REMAINING"; then
                config_sync_write_event "$OPT_EVENT" "$OPT_MAC" "$OPT_REMAINING"
                station_updated_by_remote "$OPT_MAC" "$OPT_REMAINING"
                station_associated "$OPT_MAC" 0
            fi
            ;;
        disassoc_remote)
            if [ -n "$OPT_REMAINING" ] && ! config_sync_is_duplicate_event "$OPT_EVENT" "$OPT_MAC" "$OPT_REMAINING"; then
                config_sync_write_event "$OPT_EVENT" "$OPT_MAC" "$OPT_REMAINING"
                station_updated_by_remote "$OPT_MAC" "$OPT_REMAINING"
                station_disassociated "$OPT_MAC" 0
            fi
            ;;
        list_remote)
            if ! config_sync_is_duplicate_event "$OPT_EVENT" "$OPT_MAC" "$OPT_REMAINING"; then
                config_sync_write_event "$OPT_EVENT"
                announce_all_quotas_to_remote
            fi
            ;;
        test)
            ;;
        *)
            logger "Unknown event '$OPT_EVENT'"
            RETURN_VALUE=1
            ;;
    esac
else
    initialized=$(ebtables -L | grep "$PACKAGE")
    if [ -n "$initialized" ]; then
        logger "Service $PACKAGE was running, stopping"
        stop_service
    fi
    logger "Feature $PACKAGE disabled"
fi

lock -u "$LOCKFILE"

exit $RETURN_VALUE
