#!/bin/sh

[ -n "$1" ] || exit

. /lib/functions.sh

# retrieve args
OMR_QUOTA_INTERFACE="$1"
shift

_PERSIST_DIR="${OMR_QUOTA_STATE_DIR:-/etc/omr-quota/state}"
# Runtime marker directory (tmpfs). This is a cross-package interface, not a
# private detail: omr-tracker's post-tracking.d/002-error reads the *.cut
# markers here so a quota cut is not mistaken for a connectivity failure, and
# it resolves the directory as OMR_QUOTA_RUNTIME_DIR -- so that is the
# canonical name. OMR_QUOTA_THROTTLE_STATE_DIR stays accepted: it is what the
# omr-quota unit tests already pass.
_TSTATE_DIR="${OMR_QUOTA_RUNTIME_DIR:-${OMR_QUOTA_THROTTLE_STATE_DIR:-/tmp/omr-quota}}"

# The poll interval, at least 5 s: 0 (which the config accepts) made the main
# loop fork vnstat, jsonfilter and ifstatus back to back without a pause.
case "${OMR_QUOTA_INTERVAL:-}" in
	''|*[!0-9]*) ;;
	*)
		if [ "$OMR_QUOTA_INTERVAL" -lt 5 ]; then
			[ "${OMR_QUOTA_UNDO:-0}" = "1" ] || \
				logger -t "OMR-QUOTA" "Check interval of ${OMR_QUOTA_INTERVAL}s for $OMR_QUOTA_INTERFACE is too short, using 5s"
			OMR_QUOTA_INTERVAL=5
		fi
		;;
esac

# Enforcement markers, keyed by this daemon's identity ($1 -- the interface
# name for "interface" sections, "global_<id>" for "global" ones). Besides
# telling the daemon what it did on the previous loop, they let
# /etc/init.d/omr-quota lift the enforcement of a quota that has been
# disabled or removed (OMR_QUOTA_UNDO=1, see _undo_enforcement below):
# without them a cut interface stayed down, and a throttled one stayed
# shaped, once its quota was turned off -- nothing else knew about them.
#   <id>.cut         interfaces this daemon's cut covers
#   <id>.ifdown      of those, the ones it actually called ifdown on
#   <id>.throttled   interfaces this daemon shapes with _apply_throttle
#   <id>.downstream  interfaces shaped by the daily-budget speed limit
#   <id>.blocklan    this daemon claims the LAN block (firewall lan input
#                    DROP), which every quota with block_lan shares
#
# .cut and .ifdown are deliberately two files. .cut is the cross-package
# one: 002-error reads its *contents* so that any interface a quota cut
# covers -- including the members of a global quota -- is not mistaken for a
# connectivity failure and "recovered" with an ifup. .ifdown is the narrower
# record of what this daemon itself took down, and it is the only thing it
# will ever raise again: an interface the admin (or another service) had
# already put down is not this daemon's to raise, and raising it on every
# poll meant the quota daemon fought whoever had brought it down, once per
# poll interval, for as long as both stayed in disagreement.
_cut_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.cut"
_ifdown_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.ifdown"
_tstate_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.throttled"
_downstream_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.downstream"
_blocklan_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.blocklan"
# High-water mark of an enforced quota's usage, see _hwm_* below
_hwm_file="${_TSTATE_DIR}/${OMR_QUOTA_INTERFACE}.hwm"
# persistent scope: once exceeded, stay exceeded even across month boundaries
_persist_file="${_PERSIST_DIR}/${OMR_QUOTA_INTERFACE}.exceeded"

# exceedance_action=block is the LAN block alone: the WAN(s) stay up, so the
# router keeps its own connectivity -- a remote-management tunnel riding the
# metered WAN, the VPS sync -- while LAN clients lose internet. A cut takes
# all of that down with the interface. The block is the very one block_lan
# adds to a cut, so the action implies the option.
[ "${OMR_QUOTA_ACTION:-cut}" = "block" ] && OMR_QUOTA_BLOCK_LAN=1

_get_real_interface() {
	local iface="$1"
	local real
	local _cache="${_TSTATE_DIR}/${iface}.realdev"
	case "$iface" in
		@*)
			real="$(ifstatus "$iface" | jsonfilter -q -e '@["device"]')"
			;;
		*)
			real="$(ifstatus "$iface" | jsonfilter -q -e '@.l3_device')"
			;;
	esac
	# ifstatus reports no l3_device while the interface is administratively
	# down (e.g. cut by this same script for quota enforcement) -- fall back
	# to the last known device name so vnstat usage lookups keep working.
	# Without this, a cut interface reads back as 0 bytes used next cycle,
	# looks like the quota is no longer exceeded, and gets brought back up
	# only to be cut again next cycle: an infinite up/down flap.
	if [ -n "$real" ]; then
		mkdir -p "$_TSTATE_DIR"
		echo "$real" > "$_cache"
	elif [ -f "$_cache" ]; then
		real="$(cat "$_cache")"
	fi
	printf '%s' "$real"
}

# _vnstat_month_now <date format>: the month vnstat counts in right now.
# vnstat dates the month bucket of "now - (MonthRotate - 1) days" (dbsql.c),
# so a MonthRotate set in vnstat.conf moves the boundary the same way here.
_VNSTAT_CONF="${OMR_QUOTA_VNSTAT_CONF:-/etc/vnstat.conf}"
_vnstat_month_now() {
	local rot now
	rot="$(awk '$1 == "MonthRotate" { v = $2 } END { print v }' "$_VNSTAT_CONF" 2>/dev/null)"
	case "$rot" in ''|*[!0-9]*) rot=1 ;; esac
	[ "$rot" -ge 1 ] && [ "$rot" -le 28 ] || rot=1
	now="$(date +%s)"
	date -d "@$(( now - (rot - 1) * 86400 ))" "$1" 2>/dev/null
}

# _vnstat_bucket_is_current <vnstat json>: does traffic.month[-1] belong to
# the month vnstat counts in right now? A bucket without a readable date is
# trusted, as before.
_vnstat_bucket_is_current() {
	local y m cur
	y="$(printf '%s' "$1" | jsonfilter -q -e '@.interfaces[0].traffic.month[-1].date.year' | tr -d "\n")"
	m="$(printf '%s' "$1" | jsonfilter -q -e '@.interfaces[0].traffic.month[-1].date.month' | tr -d "\n")"
	case "$y$m" in ''|*[!0-9]*) return 0 ;; esac
	cur="$(_vnstat_month_now '+%Y %m')"
	case "$cur" in [0-9]*" "[0-9]*) ;; *) return 0 ;; esac
	[ "${cur% *}" = "$y" ] && [ "${cur#* }" -eq "$m" ] 2>/dev/null
}

_vnstat_usage() {
	local dev="$1"
	local json rx_bytes tx_bytes
	# Third field tells the caller whether both counters were read.  Zero is a
	# valid value (notably after a month rollover); a missing device, a failed
	# vnstat query or incomplete JSON is not.  Treating those failures as real
	# zeroes makes an enforced interface get ifup'ed between polling cycles.
	[ -z "$dev" ] && { printf '0 0 0'; return; }

	if [ -n "$OMR_QUOTA_BEGINDATE" ]; then
		# Usage since a date has to come from the *daily* buckets. The obvious
		# "vnstat -i <dev> -b <date> --json" + traffic.total does not work:
		# -b only bounds vnstat's list output, while traffic.total is the
		# interface's whole recorded history, so the date was silently ignored
		# and a begindate quota counted every byte vnstat had ever seen for
		# that device. Proved on the bench: -b <first of month>, -b
		# <yesterday> and -b <a future day> all returned 694221137, while the
		# daily sums for the same dates were 694221137, 411937225 and 0.
		# Note vnstat only keeps a limited daily history (DailyDays, 30 by
		# default), so a begindate older than that counts from the oldest day
		# still in the database.
		json="$(vnstat -i "$dev" --json d -b "$OMR_QUOTA_BEGINDATE" 2>/dev/null)" || {
			printf '0 0 0'; return
		}
		# printf in awk rather than print: print applies awk's OFMT/CONVFMT
		# to a non-integral or very large sum (%.6g), which the numeric check
		# below would reject; "%.0f" always renders a plain integer. (Busybox
		# awk on the bench prints these magnitudes as integers either way --
		# this is belt and braces, not a reproduced failure.)
		rx_bytes="$(printf '%s' "$json" | jsonfilter -q -e '@.interfaces[0].traffic.day[*].rx' | awk '{s+=$1} END{printf "%.0f", s+0}')"
		tx_bytes="$(printf '%s' "$json" | jsonfilter -q -e '@.interfaces[0].traffic.day[*].tx' | awk '{s+=$1} END{printf "%.0f", s+0}')"
	else
		json="$(vnstat -i "$dev" --json 2>/dev/null)" || {
			printf '0 0 0'; return
		}
		rx_bytes="$(printf '%s' "$json" | jsonfilter -q -e '@.interfaces[0].traffic.month[-1].rx' | tr -d "\n")"
		tx_bytes="$(printf '%s' "$json" | jsonfilter -q -e '@.interfaces[0].traffic.month[-1].tx' | tr -d "\n")"
		# vnstat knows the device but has not written a bucket for it yet.
		# That is a real zero, not an unreadable sample: it is the state of
		# every freshly registered device until vnstatd's next flush, five
		# minutes away by default. Rejecting it made such a WAN meter
		# *nothing at all* for those minutes -- the live counters included,
		# since a delta with no reference to sit on is not added. Measured on
		# a router: 20 MiB pushed right after the device was registered, and
		# the quota still read 0 KiB a minute later. Only a device vnstat
		# does not know at all (no interfaces[0].name to match) stays
		# unreadable.
		if [ -z "$rx_bytes" ] && [ -z "$tx_bytes" ] && \
		   [ "$(printf '%s' "$json" | jsonfilter -q -e '@.interfaces[0].name' | tr -d "\n")" = "$dev" ]; then
			rx_bytes=0
			tx_bytes=0
		fi
		# month[-1] is the newest month vnstat has a bucket for, not
		# necessarily this one: vnstatd only opens a new bucket once the
		# device moves traffic again. A PPPoE/3G WAN this quota cut has no
		# device at all, so it kept reading last month's full bucket after
		# the month change and the cut never lifted.
		if [ -n "$rx_bytes$tx_bytes" ] && ! _vnstat_bucket_is_current "$json"; then
			rx_bytes=0
			tx_bytes=0
		fi
	fi

	case "$rx_bytes" in ''|*[!0-9]*) printf '0 0 0'; return ;; esac
	case "$tx_bytes" in ''|*[!0-9]*) printf '0 0 0'; return ;; esac
	printf '%s %s 1' "$(( rx_bytes / 1024 ))" "$(( tx_bytes / 1024 ))"
}

# ── Live kernel counters ──────────────────────────────────────────────────────
#
# vnstat is the metering *reference* -- it survives reboots, knows about
# months and is what the whole package is configured against -- but it is not
# live: vnstatd keeps its counters in memory and only writes them to its
# database every SaveInterval minutes (5 by default, and OpenWrt ships the
# stock vnstat.conf with every interval commented out, so that default is what
# runs). `vnstat --json` is therefore up to five minutes behind reality, and a
# quota checked against it alone is blind for exactly that long: on a fast WAN
# a single speedtest passes gigabytes inside one save window and the quota is
# only *seen* as reached at the next flush, long after it was crossed. Field
# report on a Starlink WAN: the quota was overshot by 42% before the cut.
#
# So the live traffic the kernel has counted since vnstat's last sample is
# added on top of it, from /sys/class/net/<dev>/statistics/{rx,tx}_bytes.
# State file "live.<id>.<dev>" in the runtime dir, one per quota *and* device:
#
#   <valid until> <vnstat rx> <vnstat tx> <anchor rx> <anchor tx> <last rx> <last tx>
#
# all in KiB except the first field, a /proc/uptime deadline. Usage is
# `vnstat_now + (kernel_now - anchor)`, and the anchor moves to the *previous*
# poll's kernel reading every time the vnstat sample changes, i.e. every time
# vnstatd has flushed. The traffic between that poll and the flush is then
# counted twice -- at most one poll interval of it, which errs on the side of
# cutting slightly early; anchoring on the *current* reading instead would
# lose that same window entirely. The error never accumulates: vnstat's value
# is absolute and the anchor is reset at every flush.
#
# The <id> in the file name is what keeps two quotas metering the same device
# apart. The stored vnstat sample is what tells the next poll whether vnstatd
# has flushed, and they do not necessarily read the same one: a section with a
# "begindate" sums daily buckets from that date, one without reads the month
# bucket. Sharing a single live.<dev> made every poll see a "changed" sample,
# re-anchor on the other daemon's last reading, and hand back one poll interval
# of traffic instead of everything since the flush -- exactly the blindness
# this mechanism exists to remove (tests/test_004, case 079b).
#
# Resolution goes from SaveInterval (5 min) down to the poll interval, without
# touching vnstat's own configuration.
#
# A device can vanish with traffic vnstat has not flushed yet: a PPPoE or 3G
# WAN has no device at all once this daemon cuts it. Its last reading then
# stands -- a device that is gone counts nothing more -- so the delta it had
# is kept until vnstatd flushes (the sample changes, the anchor moves onto
# that last reading and the delta drops to 0, vnstat holding it all now).
# Dropping it to 0 at once read the cut WAN back below its quota from the
# stale vnstat sample, and raised it on the next poll: over-quota traffic
# flowed again and the WAN flapped until the flush. Without a usable state
# there is nothing to keep, and metering is plain vnstat, as it used to be.
_SYSFS_NET="${OMR_QUOTA_SYSFS_DIR:-/sys/class/net}"

# How long a live state stays usable without a refresh. Past it the state is
# thrown away and the anchor starts over at the current reading: a daemon that
# comes back after a real gap (service stopped, quota disabled for a while)
# would otherwise re-anchor on a kernel reading from before the gap, while
# vnstat has had all that time to catch up on its own -- counting the gap's
# traffic twice.
case "${OMR_QUOTA_INTERVAL:-}" in
	''|*[!0-9]*) _live_ttl=180 ;;
	*)           _live_ttl=$(( OMR_QUOTA_INTERVAL * 3 )); [ "$_live_ttl" -lt 60 ] && _live_ttl=60 ;;
esac

# _uptime: whole seconds since boot. Monotonic on purpose -- an NTP step at
# boot must not make a live state look fresh (or stale) by hours. The runtime
# dir is tmpfs, so a reboot drops every state file along with the clock.
_uptime() {
	local up rest
	read -r up rest < /proc/uptime 2>/dev/null || return 1
	up="${up%%.*}"
	case "$up" in ''|*[!0-9]*) return 1 ;; esac
	printf '%s' "$up"
}

# _kernel_counters <dev>: "<rx KiB> <tx KiB>" straight from the kernel, or a
# non-zero exit when the device has no readable counters.
_kernel_counters() {
	local dev="$1" rx tx
	[ -n "$dev" ] || return 1
	rx="$(cat "${_SYSFS_NET}/${dev}/statistics/rx_bytes" 2>/dev/null)"
	tx="$(cat "${_SYSFS_NET}/${dev}/statistics/tx_bytes" 2>/dev/null)"
	case "$rx" in ''|*[!0-9]*) return 1 ;; esac
	case "$tx" in ''|*[!0-9]*) return 1 ;; esac
	printf '%s %s' "$(( rx / 1024 ))" "$(( tx / 1024 ))"
}

# _live_state_file <id> <dev>: this quota's live state for one device. Keep
# in sync with the read-only twin in the rpcd plugin, which has to address
# exactly the same file.
_live_state_file() {
	printf '%s/live.%s.%s' "$_TSTATE_DIR" "$1" "$(echo "$2" | tr '/' '-')"
}

# _live_delta <dev> <vnstat rx KiB> <vnstat tx KiB>: "<rx KiB> <tx KiB>" of
# traffic the kernel has counted since that vnstat sample, updating this
# quota's live state for the device. "0 0" when the feature is off, or when
# the counters are unreadable and there is no usable state to hold on to.
# Keep in sync with the read-only twin in the rpcd plugin.
_live_delta() {
	local dev="$1" vrx="$2" vtx="$3"
	local now kernel krx ktx file ok n
	local exp pvrx pvtx arx atx lrx ltx

	[ "${OMR_QUOTA_LIVE_COUNTERS:-1}" = "0" ] && { printf '0 0'; return; }
	now="$(_uptime)"                || { printf '0 0'; return; }

	file="$(_live_state_file "$OMR_QUOTA_INTERFACE" "$dev")"
	ok=0
	if [ -f "$file" ]; then
		read -r exp pvrx pvtx arx atx lrx ltx < "$file"
		ok=1
		for n in "${exp:-x}" "${pvrx:-x}" "${pvtx:-x}" "${arx:-x}" "${atx:-x}" "${lrx:-x}" "${ltx:-x}"; do
			case "$n" in ''|*[!0-9]*) ok=0 ;; esac
		done
		[ "$ok" = "1" ] && [ "$now" -gt "$exp" ] && ok=0
	fi

	if kernel="$(_kernel_counters "$dev")"; then
		krx="${kernel%% *}"; ktx="${kernel##* }"
	elif [ "$ok" = "1" ]; then
		# device gone: its last reading stands, see above
		krx="$lrx"; ktx="$ltx"
	else
		printf '0 0'; return
	fi

	if [ "$ok" != "1" ]; then
		# no usable state: anchor here, so this poll contributes nothing
		arx="$krx"; atx="$ktx"
	elif [ "$vrx" != "$pvrx" ] || [ "$vtx" != "$pvtx" ]; then
		# vnstatd flushed since the previous poll -- re-anchor on it
		arx="$lrx"; atx="$ltx"
	fi
	# device recreated (ifdown/ifup, modem reconnect, driver reload): the
	# kernel counters restart at 0, so anything below the anchor is a reset
	[ "$krx" -lt "$arx" ] && arx="$krx"
	[ "$ktx" -lt "$atx" ] && atx="$ktx"

	mkdir -p "$_TSTATE_DIR"
	printf '%s %s %s %s %s %s %s' \
		"$(( now + _live_ttl ))" "$vrx" "$vtx" "$arx" "$atx" "$krx" "$ktx" \
		> "${file}.new" && mv "${file}.new" "$file"

	printf '%s %s' "$(( krx - arx ))" "$(( ktx - atx ))"
}

# _vnstat_register <dev>: make vnstat count this device.
#
# `_track_vnstat` in the init does this at service start, but only for an
# interface netifd can resolve *right then*. A WAN that is down at that moment
# -- no carrier on the port, modem not up yet, DHCP not finished -- is skipped,
# and nothing ever comes back to it: the procd trigger is on the *network
# config*, not on interface state, and the package ships no hotplug. Found on a
# router whose WAN port had nothing plugged in when the image first booted:
# `vnstat -i eth1 --json` answered "No interface matching eth1 found in
# database" for good, so a quota enabled on that WAN read 0 bytes of usage
# forever and silently never applied -- and nothing in the UI said so.
#
# So the daemon repairs it itself, the first time it resolves a device vnstat
# does not know. Once per device per boot: the marker is a *directory* in the
# tmpfs runtime dir, created atomically, so two daemons metering the same
# device (an interface quota and a global one) cannot both add it to the list.
# One attempt only -- if it does not help, retrying every poll would churn uci
# and restart vnstatd forever.
_VNSTAT_INIT="${OMR_QUOTA_VNSTAT_INIT:-/etc/init.d/vnstat}"

_vnstat_register() {
	local dev="$1" list marker
	[ -n "$dev" ] || return
	[ "${OMR_QUOTA_VNSTAT_AUTOADD:-1}" = "0" ] && return
	marker="${_TSTATE_DIR}/vnstatadd.$(echo "$dev" | tr '/' '-')"
	# already attempted for this device since boot
	[ -d "$marker" ] && return
	# A device name can outlive the device itself (the realdev cache keeps
	# serving it while the interface is cut): only ever register something
	# that exists right now.
	_kernel_counters "$dev" >/dev/null 2>&1 || return
	# vnstat itself is the authority on what it counts, not the uci list: a
	# device can sit in the list and still be missing from the database, or
	# be counted without being listed. Known but with no usable sample yet
	# just means vnstatd has not written a bucket for it -- that comes on its
	# own at the next flush, and must not consume the one repair attempt.
	vnstat --dbiflist 2>/dev/null | grep -qw -- "$dev" && return
	[ -n "$(uci -q get vnstat.@vnstat[-1])" ] || return

	mkdir -p "$_TSTATE_DIR"
	mkdir "$marker" 2>/dev/null || return

	list="$(uci -q get vnstat.@vnstat[-1].interface)"
	case " $list " in
		*" $dev "*) ;;
		*)
			uci -q add_list "vnstat.@vnstat[-1].interface=$dev"
			uci -q commit vnstat
			;;
	esac
	logger -t "OMR-QUOTA" "vnstat was not counting $dev: registering it so the quota on $OMR_QUOTA_INTERFACE can be metered"
	"$_VNSTAT_INIT" reload >/dev/null 2>&1
}

# _begindate_in_future <YYYY-MM-DD>: true when the begindate window has not
# opened yet. The live kernel delta is traffic from *today*, i.e. before such
# a window, so it must not be added: a 1 KiB quota starting in 2099 read 2 KiB
# and cut the WAN (bench case 89). An empty or malformed date is "not future".
_begindate_in_future() {
	local d
	d="$(printf '%s' "$1" | tr -d '-')"
	case "$d" in ''|*[!0-9]*) return 1 ;; esac
	[ "$d" -gt "$(date +%Y%m%d)" ]
}

# _interface_usage <iface>: "<rx KiB> <tx KiB> <complete>" for one metered
# interface -- its vnstat sample with the live kernel delta added on top. The
# delta is only trusted when the vnstat sample itself is complete: without a
# reference to add it to it would be meaningless.
_interface_usage() {
	local iface="$1"
	local dev usage rest rx tx complete delta
	dev="$(_get_real_interface "$iface")"
	usage="$(_vnstat_usage "$dev")"
	rx="${usage%% *}"; rest="${usage#* }"
	tx="${rest%% *}"; complete="${rest##* }"
	if [ "$complete" = "1" ]; then
		if ! _begindate_in_future "$OMR_QUOTA_BEGINDATE"; then
			delta="$(_live_delta "$dev" "$rx" "$tx")"
			rx=$(( rx + ${delta%% *} ))
			tx=$(( tx + ${delta##* } ))
		fi
	else
		# no usable sample: vnstat may simply not know this device
		_vnstat_register "$dev"
	fi
	printf '%s %s %s' "$rx" "$tx" "$complete"
}

# SQM (cake/htb+fq_codel via /usr/lib/sqm) owns the root qdisc of the devices
# it shapes. Our tbf replaces it, so a device that SQM manages has to be
# handed over explicitly: stop SQM before shaping (otherwise its state file
# claims it is running while our tbf is installed, and any "sqm reload" puts
# cake back on top of us) and start it again after unshaping (otherwise the
# user's shaper is silently gone once a quota clears, while SQM still reports
# it active -- the same failure mode as issue #4329, where mptcp's fq default
# wiped cake). Paths are overridable for the unit tests.
_SQM_RUN="${OMR_QUOTA_SQM_RUN:-/usr/lib/sqm/run.sh}"

# _sqm_manages <dev>: is this device shaped by SQM (running, or configured)?
_sqm_manages() {
	local dev="$1" state_dir sec
	[ -n "$dev" ] || return 1
	[ -x "$_SQM_RUN" ] || return 1
	state_dir="${OMR_QUOTA_SQM_STATE_DIR:-$(. /etc/sqm/sqm.conf 2>/dev/null; echo "${SQM_STATE_DIR:-/var/run/sqm}")}"
	[ -f "${state_dir}/${dev}.state" ] && return 0
	for sec in $(uci -q show sqm 2>/dev/null | sed -n "s/^sqm\.\([^.]*\)\.interface='${dev}'\$/\1/p"); do
		[ "$(uci -q get sqm.${sec}.enabled)" = "1" ] && return 0
	done
	return 1
}

# _shape_device <dev> <dl kbit> <ul kbit> <burst> <latency>: bidirectional
# shaping, the only way tc can limit what a device *receives*.
#
# Egress is a tbf on the device's own root qdisc. Ingress cannot be shaped
# directly, so all incoming traffic is redirected (u32/mirred) to an IFB
# device that carries its own tbf -- the standard Linux trick. The IFB name
# is "ifb-<dev>" with "/" sanitized to "-", since MPTCP sub-interfaces can
# contain one.
#
# Both the throttle action and the daily-budget speed limit go through this:
# the budget limit used to install the egress tbf only, which on a WAN device
# limits upload and leaves download untouched. Measured on the bench with a
# derived 2 Mbit/s limit: upload 1897 kbit/s, download 20003 kbit/s, i.e. a
# "limit speed using remaining daily volume" that did not slow the direction
# the volume is actually spent on.
#
# It runs on every poll while a quota is exceeded. Replacing the qdiscs and
# the redirect filter each time reset the shaper's state, and on an SQM device
# logged "Taking over the SQM shaper" and ran "sqm stop" every interval: the
# shaping is only redone when it changed or something removed it (the ifup
# hotplug's "mptcp reload" replaces the root qdisc). shaped.<dev> in the
# runtime dir records what was installed.
_shaped_file() {
	printf '%s/shaped.%s' "$_TSTATE_DIR" "$(echo "$1" | tr '/' '-')"
}

_shape_device() {
	local dev="$1" dl_kbit="$2" ul_kbit="$3" burst="$4" latency="$5"
	local ifb_dev="ifb-$(echo "$dev" | tr '/' '-')"
	local shaped want="$2 $3 $4 $5"
	shaped="$(_shaped_file "$dev")"
	if [ "$(cat "$shaped" 2>/dev/null)" = "$want" ] && \
	   tc qdisc show dev "$dev" root 2>/dev/null | grep -q '^qdisc tbf ' && \
	   tc filter show dev "$dev" parent ffff: 2>/dev/null | grep -q "Redirect to device $ifb_dev)" && \
	   tc qdisc show dev "$ifb_dev" root 2>/dev/null | grep -q '^qdisc tbf '; then
		return 0
	fi
	if _sqm_manages "$dev"; then
		logger -t "OMR-QUOTA" "Taking over the SQM shaper on $dev"
		"$_SQM_RUN" stop "$dev" >/dev/null 2>&1
	fi
	modprobe ifb 2>/dev/null || true
	if ! ip link show "$ifb_dev" > /dev/null 2>&1; then
		ip link add name "$ifb_dev" type ifb
	fi
	ip link set "$ifb_dev" up
	tc qdisc replace dev "$dev" root tbf rate "${ul_kbit}kbit" burst "$burst" latency "$latency"
	tc qdisc add dev "$dev" handle ffff: ingress 2>/dev/null || true
	tc filter del dev "$dev" parent ffff: 2>/dev/null || true
	tc filter add dev "$dev" parent ffff: protocol all u32 match u32 0 0 \
		action mirred egress redirect dev "$ifb_dev"
	tc qdisc replace dev "$ifb_dev" root tbf rate "${dl_kbit}kbit" burst "$burst" latency "$latency"
	mkdir -p "$_TSTATE_DIR"
	printf '%s' "$want" > "$shaped"
}

# _default_root_qdisc <dev> [iface]: the root qdisc mptcp's init puts on a
# WAN device (interface_multipath_settings): the qdisc of the device's
# "config device" section, else the interface's, else fq.
_default_root_qdisc() {
	local dev="$1" iface="$2" qdisc="" sec v
	[ -n "$iface" ] && qdisc="$(uci -q get "network.${iface}.qdisc")"
	# the last device section named after the device, as config_foreach finds
	sec="$(uci -q show network 2>/dev/null | awk -v dev="$dev" -v q="'" '{
		eq = index($0, "="); k = substr($0, 1, eq - 1); v = substr($0, eq + 1)
		gsub(q, "", v)
		n = split(k, p, ".")
		if (n == 2) type[p[2]] = v
		else if (n == 3 && p[3] == "name" && v == dev && type[p[2]] == "device") s = p[2]
	} END { print s }')"
	if [ -n "$sec" ]; then
		v="$(uci -q get "network.${sec}.qdisc")"
		[ -n "$v" ] && qdisc="$v"
	fi
	printf '%s' "${qdisc:-fq}"
}

# _unshape_device <dev> [iface]: undo _shape_device, including the IFB
# device, and give the device back to SQM if that is what shapes it
# normally, else to the root qdisc mptcp sets (deleting the tbf left the
# kernel default in place until the next "mptcp reload").
#
# Only what is still ours is undone. A device can be listed by two markers
# (.throttled and .downstream, and _undo_enforcement walks both), and the
# second pass deleted the cake root qdisc the first had just handed back to
# SQM, while "sqm start" then did nothing since its state file existed: SQM
# reported running with no shaper on the WAN (the #4329 failure). The root
# qdisc goes only while it is a tbf, the ingress qdisc only while its filter
# redirects to our IFB (SQM's own ingress redirects to ifb4<dev>).
_unshape_device() {
	local dev="$1" iface="$2"
	local ifb_dev="ifb-$(echo "$dev" | tr '/' '-')"
	local shaped root_tbf=0
	shaped="$(_shaped_file "$dev")"
	tc qdisc show dev "$dev" root 2>/dev/null | grep -q '^qdisc tbf ' && root_tbf=1
	[ -f "$shaped" ] || [ "$root_tbf" = "1" ] || return 0
	rm -f "$shaped"
	if tc filter show dev "$dev" parent ffff: 2>/dev/null | grep -q "Redirect to device $ifb_dev)"; then
		tc qdisc del dev "$dev" ingress 2>/dev/null || true
	fi
	if ip link show "$ifb_dev" > /dev/null 2>&1; then
		tc qdisc del dev "$ifb_dev" root 2>/dev/null || true
		ip link del "$ifb_dev" 2>/dev/null || true
	fi
	[ "$root_tbf" = "1" ] && tc qdisc del dev "$dev" root 2>/dev/null
	if _sqm_manages "$dev"; then
		logger -t "OMR-QUOTA" "Restoring the SQM shaper on $dev"
		"$_SQM_RUN" start "$dev" >/dev/null 2>&1
	elif [ "$root_tbf" = "1" ] && [ "$(uci -q get "qos.${iface:-$dev}.enabled")" != "1" ]; then
		# a qos-scripts device is left alone, as mptcp's init does
		tc qdisc replace dev "$dev" root "$(_default_root_qdisc "$dev" "$iface")" 2>/dev/null
	fi
}

_apply_throttle() {
	_shape_device "$1" \
		"$(( ${OMR_QUOTA_THROTTLE_DL:-1} * 1000 ))" \
		"$(( ${OMR_QUOTA_THROTTLE_UL:-1} * 1000 ))" \
		32768 400ms
}

_remove_throttle() {
	_unshape_device "$1" "$2"
}

# _wait_iface_up <iface> [seconds]: after ifup, wait (bounded) for netifd to
# report the interface up. A throttle's egress tbf must be installed *after*
# this: the ifup hotplug ("mptcp reload <dev>") replaces the device's root
# qdisc, so a tbf installed while the interface was still down was wiped a
# second later and the upload ran unshaped until the next poll interval.
# `command sleep` deliberately bypasses any shell-function override of sleep
# (the unit tests use one to stop the main loop after one iteration).
_wait_iface_up() {
	local iface="$1" n=0 max="${2:-10}"
	while [ "$n" -lt "$max" ]; do
		[ "$(ifstatus "$iface" | jsonfilter -q -e '@.up')" = "true" ] && return 0
		command sleep 1
		n=$((n + 1))
	done
	return 1
}

# _throttled_dev <dev>: the exceedance throttle shapes this device on the
# current poll (the main loop's _throttle_devs). Both shapers use the same
# root tbf and IFB, so while the quota is exceeded the throttle alone owns
# the device: the budget limit (method 2) used to replace it right back,
# twice per poll, each time stopping SQM again.
_throttled_dev() {
	case " ${_throttle_devs:-} " in *" $1 "*) return 0 ;; esac
	return 1
}

_apply_downstream_limit() {
	local rate_kbit="$1"
	local iface dev list prev rec devs
	[ -z "$rate_kbit" ] && return
	list="${OMR_QUOTA_DOWN_INTERFACES:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}"
	# down_interfaces can be edited while a daily-budget limit is active (the
	# daemon is relaunched with the new list). Drop the shaper from the
	# interfaces that are no longer targets first, or their tbf stays in
	# place for good: the marker written below is the only record of what was
	# shaped, and it is about to be overwritten with the new list.
	if [ -f "$_downstream_file" ]; then
		prev="$(cat "$_downstream_file")"
		for iface in $prev; do
			case " $list " in *" $iface "*) continue ;; esac
			dev="$(_get_real_interface "$iface")"
			[ -n "$dev" ] && ! _throttled_dev "$dev" && _unshape_device "$dev" "$iface"
		done
	fi
	# A device the throttle shapes is the throttle's: listed here as well,
	# lifting the throttle unshaped it twice (_lift_throttle, then
	# _remove_downstream_limit or _undo_enforcement). Written before the
	# shaping, so nothing is ever shaped without a record.
	devs=""
	rec=""
	for iface in $list; do
		dev="$(_get_real_interface "$iface")"
		[ -n "$dev" ] && _throttled_dev "$dev" && continue
		rec="${rec:+$rec }$iface"
		[ -n "$dev" ] && devs="$devs $dev"
	done
	mkdir -p "$_TSTATE_DIR"
	if [ -n "$rec" ]; then
		printf '%s' "$rec" > "$_downstream_file"
	else
		rm -f "$_downstream_file"
	fi
	for dev in $devs; do
		# the derived rate is a volume budget, so it caps both directions
		_shape_device "$dev" "$rate_kbit" "$rate_kbit" 5k 200ms
	done
}

_remove_downstream_limit() {
	local iface dev list
	[ -f "$_downstream_file" ] || return
	# what was actually shaped, not what the current environment says: the
	# two differ exactly when down_interfaces changed under a live limit
	list="$(cat "$_downstream_file")"
	for iface in ${list:-${OMR_QUOTA_DOWN_INTERFACES:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}}; do
		dev="$(_get_real_interface "$iface")"
		[ -n "$dev" ] && ! _throttled_dev "$dev" && _unshape_device "$dev" "$iface"
	done
	rm -f "$_downstream_file"
}

# LAN block. firewall.zone_lan.input=DROP is what keeps LAN clients off the
# proxies while the quota is cut (every transparent-proxy redirect -- ss,
# xray, v2ray, hysteria -- lands in the router's own input path), but on
# its own it also cuts LuCI, SSH, DNS and DHCP: the admin can no longer
# even open the quota page to lift the block, and clients lose their leases
# while it lasts. So the block always comes with ACCEPT rules for the
# router's own services, which fw4 evaluates before the zone policy. The
# rules are named UCI sections (idempotent to re-create, removed as a set
# on unblock); LuCI/SSH ports are read from the live uhttpd/dropbear config
# with the stock defaults as fallback.
_LAN_ACCESS_RULES="omr_quota_lan_tcp omr_quota_lan_udp omr_quota_lan_icmp omr_quota_lan_fwd_local omr_quota_lan_fwd"

# _uci_ports <default> [values...]: print the sorted unique numeric ports
# found in <values> (bare ports or listen specs like 0.0.0.0:80 / [::]:443),
# or <default> when none is found.
_uci_ports() {
	local dflt="$1" v p out=""
	shift
	for v in "$@"; do
		p="${v##*:}"
		case "$p" in ""|*[!0-9]*) continue ;; esac
		out="$out $p"
	done
	[ -n "$out" ] || out=" $dflt"
	printf '%s\n' $out | sort -un | tr '\n' ' ' | sed 's/ $//'
}

# TCP services LAN clients keep reaching on the router: LuCI (uhttpd),
# SSH (dropbear) and DNS. Each service falls back to its own default.
_lan_access_tcp_ports() {
	_uci_ports "" \
		$(_uci_ports "80 443" $(uci -q get uhttpd.main.listen_http) $(uci -q get uhttpd.main.listen_https)) \
		$(_uci_ports "22" $(uci -q show dropbear 2>/dev/null | sed -n "s/^dropbear\.[^.]*\.Port='*\([0-9]*\)'*$/\1/p")) \
		53
}

_add_lan_access_rules() {
	local tcp_ports
	tcp_ports="$(_lan_access_tcp_ports)"
	uci -q set firewall.omr_quota_lan_tcp=rule
	uci -q set firewall.omr_quota_lan_tcp.name='OMR quota: keep router access (LuCI, SSH, DNS)'
	uci -q set firewall.omr_quota_lan_tcp.src='lan'
	uci -q set firewall.omr_quota_lan_tcp.proto='tcp'
	uci -q set firewall.omr_quota_lan_tcp.dest_port="$tcp_ports"
	uci -q set firewall.omr_quota_lan_tcp.target='ACCEPT'
	# DNS, DHCPv4 server (67), DHCPv6 server (547)
	uci -q set firewall.omr_quota_lan_udp=rule
	uci -q set firewall.omr_quota_lan_udp.name='OMR quota: keep router access (DNS, DHCP)'
	uci -q set firewall.omr_quota_lan_udp.src='lan'
	uci -q set firewall.omr_quota_lan_udp.proto='udp'
	uci -q set firewall.omr_quota_lan_udp.dest_port='53 67 547'
	uci -q set firewall.omr_quota_lan_udp.target='ACCEPT'
	# ping + IPv6 neighbour discovery (fw4 expands 'icmp' to icmp and ipv6-icmp)
	uci -q set firewall.omr_quota_lan_icmp=rule
	uci -q set firewall.omr_quota_lan_icmp.name='OMR quota: keep router access (ICMP)'
	uci -q set firewall.omr_quota_lan_icmp.src='lan'
	uci -q set firewall.omr_quota_lan_icmp.proto='icmp'
	uci -q set firewall.omr_quota_lan_icmp.target='ACCEPT'
	# The zone policy only covers traffic addressed to the router, which is
	# where every transparent-proxy redirect lands. Everything a LAN client
	# sends *through* the router -- a bypassed flow, the VPN tunnel, a plain
	# route out a remaining WAN -- is forwarded, not input, so input=DROP
	# left it flowing and the block stopped the proxies only. These two
	# rules close the forward path: lan->lan stays accepted (a second LAN
	# network, a macvlan, an L2 VXLAN peer bridged into the lan zone keeps
	# working locally), everything else from lan is rejected. REJECT, not
	# DROP, so a client fails fast instead of hanging on every connection.
	#
	# proto='all' is required. A rule with no proto defaults to tcp+udp, and
	# fw4 then emits it as two `meta l4proto tcp/udp` rules -- ICMP, ESP,
	# GRE and everything else would walk straight past the block.
	uci -q set firewall.omr_quota_lan_fwd_local=rule
	uci -q set firewall.omr_quota_lan_fwd_local.name='OMR quota: keep LAN to LAN traffic'
	uci -q set firewall.omr_quota_lan_fwd_local.src='lan'
	uci -q set firewall.omr_quota_lan_fwd_local.dest='lan'
	uci -q set firewall.omr_quota_lan_fwd_local.proto='all'
	uci -q set firewall.omr_quota_lan_fwd_local.target='ACCEPT'

	uci -q set firewall.omr_quota_lan_fwd=rule
	uci -q set firewall.omr_quota_lan_fwd.name='OMR quota: block forwarded LAN traffic'
	uci -q set firewall.omr_quota_lan_fwd.src='lan'
	uci -q set firewall.omr_quota_lan_fwd.dest='*'
	uci -q set firewall.omr_quota_lan_fwd.proto='all'
	uci -q set firewall.omr_quota_lan_fwd.target='REJECT'

	# Both must come FIRST among the firewall's rule sections. fw4 parses
	# every `config rule` in config-file order into one list and renders
	# forward_lan in that order (the zone's forwarding jumps come after, as
	# fw4 parses `config forwarding` only once all rules are done), and uci
	# appends new named sections at the end of the file. Without this the
	# stock `Allow-Lan-to-Wan` rule -- near the top of every OMR config --
	# jumps to accept_to_wan before the block is ever reached, and the
	# REJECT only ever bites lan->vpn. Re-asserted on every block, so a user
	# rule added later cannot slip in front. Reordered last-to-first: each
	# reorder to index 0 pushes the previous one down.
	uci -q reorder firewall.omr_quota_lan_fwd=0
	uci -q reorder firewall.omr_quota_lan_fwd_local=0
}

_del_lan_access_rules() {
	local r
	for r in $_LAN_ACCESS_RULES; do
		uci -q delete "firewall.$r"
	done
}

# The LAN block is one firewall state shared by every quota with block_lan.
# Each quota that wants it holds a claim, its <id>.blocklan marker, and the
# block is lifted only once no claim is left (_release_lan_block): lifting
# it as soon as one quota was under its limit made two quotas, one of them
# exceeded, undo and redo the block -- a firewall reload and a flash commit
# each -- on every poll.
_block_lan() {
	# The claim comes first, so a quota releasing its own at the same moment
	# already sees this one. It is written even when the block is in place:
	# after a reboot the block is still in the firewall config but the
	# tmpfs claims are gone.
	[ -f "$_blocklan_file" ] || { mkdir -p "$_TSTATE_DIR"; touch "$_blocklan_file"; }
	# Already blocked *with* the router-access rules in place. An input=DROP
	# left by an older daemon without them still gets the rules added.
	# omr_quota_lan_fwd is part of the test too: a daemon older than the
	# forward block left input=DROP and the input rules behind, and that
	# state still needs the forward pair added.
	if [ "$(uci -q get firewall.zone_lan.input)" = "DROP" ] && \
		[ -n "$(uci -q get firewall.omr_quota_lan_tcp)" ] && \
		[ -n "$(uci -q get firewall.omr_quota_lan_fwd)" ]; then
		return
	fi

	logger -t "OMR-QUOTA" "Block LAN interfaces: $1"
	uci -q set firewall.zone_lan.input='DROP'
	_add_lan_access_rules
	uci -q commit firewall
	/etc/init.d/firewall reload >/dev/null 2>&1
}

# _unblock_lan: take the LAN block out of the firewall config. Callers
# decide whether it is time to (_release_lan_block, undo mode).
_unblock_lan() {
	# Nothing to lift: input already ACCEPT and no leftover access rules
	# (e.g. input restored by hand while the rules were still there).
	if [ "$(uci -q get firewall.zone_lan.input)" = "ACCEPT" ] && \
	   [ -z "$(uci -q get firewall.omr_quota_lan_tcp)" ] && \
	   [ -z "$(uci -q get firewall.omr_quota_lan_fwd)" ]; then
		return
	fi

	logger -t "OMR-QUOTA" "Unblock LAN interfaces"
	uci -q set firewall.zone_lan.input='ACCEPT'
	_del_lan_access_rules
	uci -q commit firewall
	/etc/init.d/firewall reload >/dev/null 2>&1
}

# _lan_block_in_place: the firewall carries a quota LAN block: the access
# rules only _block_lan creates or, for a quota with block_lan as before,
# the bare input=DROP a daemon older than those rules left.
_lan_block_in_place() {
	[ -n "$(uci -q get firewall.omr_quota_lan_tcp)" ] || \
		[ -n "$(uci -q get firewall.omr_quota_lan_fwd)" ] || \
		{ [ "${OMR_QUOTA_BLOCK_LAN:-0}" = "1" ] && [ "$(uci -q get firewall.zone_lan.input)" = "DROP" ]; }
}

# _release_lan_block: this quota does not want the LAN blocked (under its
# limit, block_lan unticked, switched to throttle, disabled). Lifting used
# to depend on block_lan still being set, so unticking it while blocked, or
# switching to throttle, left the LAN blocked for good. Drops this quota's
# claim and lifts the block when no other quota holds one. A block nobody
# claims is a leftover (the claims are on tmpfs, a reboot drops them) and is
# lifted too, but not on this daemon's first poll: every quota daemon starts
# together at boot, and a quota still exceeded gets to claim the block back
# first instead of seeing it lifted and set again.
_release_lan_block() {
	local f claimed=0
	if [ -f "$_blocklan_file" ]; then
		rm -f "$_blocklan_file"
		claimed=1
	fi
	for f in "$_TSTATE_DIR"/*.blocklan; do
		[ -f "$f" ] && return 0
	done
	if [ "$claimed" = "0" ]; then
		[ "$_prev_exceeded" = "-1" ] && return 0
		_lan_block_in_place || return 0
	fi
	_unblock_lan
}

# _undo_enforcement: revert everything this daemon identity currently
# enforces, as recorded by its markers, then drop the markers. Run by
# /etc/init.d/omr-quota (OMR_QUOTA_UNDO=1) once the daemon is gone, for a
# quota that is disabled or removed and when the service is stopped for
# good -- never on a plain reload, where the relaunched daemon carries on
# and an interface it cut must stay down (no ifup/ifdown flap).
_undo_enforcement() {
	local iface dev list undone=0
	if [ -f "$_tstate_file" ]; then
		list="$(cat "$_tstate_file")"
		for iface in ${list:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}; do
			dev="$(_get_real_interface "$iface")"
			[ -n "$dev" ] && _remove_throttle "$dev" "$iface"
		done
		rm -f "$_tstate_file"
		undone=1
	fi
	if [ -f "$_downstream_file" ]; then
		list="$(cat "$_downstream_file")"
		for iface in ${list:-${OMR_QUOTA_DOWN_INTERFACES:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}}; do
			dev="$(_get_real_interface "$iface")"
			[ -n "$dev" ] && _unshape_device "$dev" "$iface"
		done
		rm -f "$_downstream_file"
		undone=1
	fi
	if [ -f "$_blocklan_file" ]; then
		_release_lan_block
		undone=1
	fi
	# Asked by the init when no active quota can want the block any more:
	# whatever block is left, claimed or not, is nobody's.
	if [ "${OMR_QUOTA_LIFT_LAN:-0}" = "1" ] && _lan_block_in_place; then
		_unblock_lan
	fi
	if [ -f "$_ifdown_file" ] || [ -f "$_cut_file" ]; then
		# Same rule as the main loop's recovery: only what this daemon took
		# down, with a lone <id>.cut (a marker predating <id>.ifdown) read as
		# the whole target list for one last time.
		if [ -f "$_ifdown_file" ]; then
			list="$(cat "$_ifdown_file")"
		else
			list="$(cat "$_cut_file")"
			list="${list:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}"
		fi
		for iface in $list; do
			[ "$(ifstatus "$iface" | jsonfilter -q -e '@.up')" = "true" ] || ifup $iface
		done
		rm -f "$_cut_file" "$_ifdown_file"
		undone=1
	fi
	rm -f "$_hwm_file"
	[ "$undone" = "1" ] && logger -t "OMR-QUOTA" "Quota enforcement for $OMR_QUOTA_INTERFACE lifted"
	return 0
}

if [ "${OMR_QUOTA_UNDO:-0}" = "1" ]; then
	_undo_enforcement
	exit 0
fi

_calculate_budget_limit() {
	local tt="$1"
	local now today eod end_ts today_ts rd rv dv seconds_left percent threshold ci

	[ -n "$OMR_QUOTA_TT" ] && [ "$OMR_QUOTA_TT" -gt 0 ] || return
	[ -n "$OMR_QUOTA_ENDDATE" ] || return

	percent=$(( tt * 100 / OMR_QUOTA_TT ))
	threshold="${OMR_QUOTA_PERCENT:-80}"
	[ "$percent" -gt "$threshold" ] || return

	# A local day is 23 or 25 hours long on a DST change: counting 1440
	# minutes from midnight gave the spring day an hour it does not have
	# (a third of the budget rate in its last hours) and left the autumn
	# one with an hour past the clamp (method 2 at full rate, method 1
	# never tripping). The day's length is read from the clock instead:
	# what is left is up to the next midnight, one second past 23:59:59,
	# which exists on every day. "today" is the day of "now", not of a
	# second date call that may already be past midnight.
	now="$(date +%s)"
	today="$(date -d "@$now" +%F 2>/dev/null)" || return
	today_ts="$(date -d "$today 0" +%s 2>/dev/null)" || return
	eod="$(date -d "$today 23:59:59" +%s 2>/dev/null)" || return
	seconds_left=$(( eod + 1 - now ))
	[ "$seconds_left" -lt 60 ] && seconds_left=60

	end_ts="$(date -d "$OMR_QUOTA_ENDDATE" +%s 2>/dev/null)" || return
	# rounded: a DST change in between makes the span an hour short or long
	rd=$(( (end_ts - today_ts + 43200) / 86400 + 1 ))
	[ "$rd" -gt 0 ] || rd=1

	rv=$(( OMR_QUOTA_TT - tt ))
	[ "$rv" -lt 0 ] && rv=0
	dv=$(( rv / rd ))

	if [ "${OMR_QUOTA_METHOD:-0}" = "1" ]; then
		# The checkpoint (last_tt) has to survive at least one poll: the main
		# loop measures "usage since the checkpoint" *after* this function
		# runs, so a calculation_interval at or below the poll interval moves
		# the checkpoint to the current usage on every single iteration, the
		# measured growth is always 0 and the budget can never be exceeded --
		# method 1 is silently inert. Seen on the bench with
		# calculation_interval=1 and interval=5: two 150 KB traffic bursts
		# against a 2 KiB budget produced no cut in five minutes. Clamp to
		# twice the poll interval and say so once.
		ci="${OMR_QUOTA_CALCULATION_INTERVAL:-120}"
		if [ "$ci" -le "${OMR_QUOTA_INTERVAL:-60}" ]; then
			if [ "${_budget_ci_warned:-0}" != "1" ]; then
				logger -t "OMR-QUOTA" "calculation_interval (${ci}s) must be longer than the poll interval (${OMR_QUOTA_INTERVAL:-60}s) to measure a daily budget: using $(( ${OMR_QUOTA_INTERVAL:-60} * 2 ))s"
				_budget_ci_warned=1
			fi
			ci=$(( ${OMR_QUOTA_INTERVAL:-60} * 2 ))
		fi
		if [ "$(( now - last_calculation ))" -gt "$ci" ]; then
			# The growth checked against cv is measured over one
			# calculation window, so cv is the share of today's budget
			# that falls in one window. It used to be a per-minute share
			# (dv / minutes left), which cut at a half (120 s window) to a
			# tenth (600 s) of the daily budget. seconds_left is at least
			# 60, and KiB * seconds stays far inside 64 bits.
			cv=$(( dv * ci / seconds_left ))
			[ "$cv" -lt 1 ] && cv=1
			last_tt=$tt
			last_calculation=$now
		fi
	elif [ "${OMR_QUOTA_METHOD:-0}" = "2" ]; then
		cb=$(( dv * 8 / seconds_left ))
		[ "$cb" -lt 1 ] && cb=1
	fi
}

# Baseline reset -- reset_exceeded (UCI/ubus) only clears the *persistent*
# scope marker, which is irrelevant for exceedance_scope=month_only: that
# scope recomputes "exceeded" from live vnstat totals every loop, so there's
# nothing else to clear and quota enforcement stays in effect until the
# calendar month rolls over. Recording a baseline (usage-so-far, subtracted
# from every future reading) makes reset_exceeded actually un-exceed a
# month_only quota immediately, without waiting for vnstat's own month
# bucket to roll over. Tagged with the metering period it was taken in, so
# the start of a new period (usage counted from 0 again) isn't permanently
# masked by a stale baseline from the previous one.
#
# The period is the one the usage is actually read from, not the calendar
# month: "since:<begindate>" for a begindate window, which never rolls on
# its own (it only changes with the begindate), else vnstat's month, which a
# MonthRotate moves off the 1st. A calendar-month tag dropped a reset on the
# 1st while the usage it offset was still counted -- begindate 2026-10-15,
# reset on Oct 25, and on Nov 1 the WAN was cut again with no new traffic.
_baseline_file="${_PERSIST_DIR}/${OMR_QUOTA_INTERFACE}.baseline"

# _baseline_period: the tag of the current metering period. Keep in sync
# with the rpcd plugin. The vnstat month is "month:YYYY-MM", not a bare
# YYYY-MM: that is the form of the calendar-month tags written before the
# period tags, which a begindate window still honours (see below), so a
# reset taken in month mode was carried into a begindate window set later
# and offset it for as long as that window lasted.
_baseline_period() {
	local p
	if [ -n "$OMR_QUOTA_BEGINDATE" ]; then
		printf 'since:%s' "$(printf '%s' "$OMR_QUOTA_BEGINDATE" | tr -cd '0-9-')"
		return
	fi
	p="$(_vnstat_month_now '+%Y-%m')"
	case "$p" in [0-9][0-9][0-9][0-9]-[0-9][0-9]) ;; *) p="$(date +%Y-%m)" ;; esac
	printf 'month:%s' "$p"
}

# _baseline_legacy_tag_holds <tag> <period>: a baseline written before the
# period tags, with the calendar month of the reset (a bare YYYY-MM), still
# belongs to the current period. Under a begindate: when the reset was not
# before the window's month. Without one: when that month is the current
# one or the next one -- a reset in the first MonthRotate-1 days of a month
# belongs to the period that started in the previous month, and a real tag
# is never ahead of the current period. Keep in sync with the rpcd plugin.
_baseline_legacy_tag_holds() {
	local tag="$1" period="$2" y m
	case "$tag" in [0-9][0-9][0-9][0-9]-[0-9][0-9]) ;; *) return 1 ;; esac
	case "$period" in
		since:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])
			y="${period#since:}"
			[ "$(printf '%s' "$tag" | tr -d '-')" -ge "$(printf '%s' "${y%-*}" | tr -d '-')" ]
			;;
		month:[0-9][0-9][0-9][0-9]-[0-9][0-9])
			period="${period#month:}"
			[ "$tag" = "$period" ] && return 0
			y="${period%-*}"; m="${period#*-}"; m="${m#0}"
			if [ "$m" -ge 12 ]; then
				y=$(( y + 1 )); m=1
			else
				m=$(( m + 1 ))
			fi
			[ "$tag" = "$(printf '%04d-%02d' "$y" "$m")" ]
			;;
		*) return 1 ;;
	esac
}

_read_baseline() {
	local tag rx0 tx0 period
	[ -f "$_baseline_file" ] || { printf '0 0'; return; }
	read -r tag rx0 tx0 < "$_baseline_file"
	period="$(_baseline_period)"
	if [ "$tag" != "$period" ]; then
		if ! _baseline_legacy_tag_holds "$tag" "$period"; then
			rm -f "$_baseline_file"
			printf '0 0'
			return
		fi
		# retagged once, so it ends with the period and not with a month
		printf '%s %s %s' "$period" "${rx0:-0}" "${tx0:-0}" > "${_baseline_file}.new" && \
			mv "${_baseline_file}.new" "$_baseline_file"
	fi
	printf '%s %s' "${rx0:-0}" "${tx0:-0}"
}

if [ "${OMR_QUOTA_RESET_BASELINE:-0}" = "1" ]; then
	_rx0=0; _tx0=0
	for iface in ${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}; do
		# same measure as the main loop (vnstat + live delta), or the
		# baseline would be smaller than the readings it is subtracted from
		usage="$(_interface_usage "$iface")"
		_usage_rest="${usage#* }"
		_rx0=$(( _rx0 + ${usage%% *} ))
		_tx0=$(( _tx0 + ${_usage_rest%% *} ))
	done
	mkdir -p "$_PERSIST_DIR"
	printf '%s %s %s' "$(_baseline_period)" "$_rx0" "$_tx0" > "$_baseline_file"
	rm -f "$_hwm_file"
	logger -t "OMR-QUOTA" "Reset quota baseline for $OMR_QUOTA_INTERFACE (rx=${_rx0}KB tx=${_tx0}KB)"
fi

# Within one metering period, the usage of an enforced quota must not go
# down. It does when a cut PPPoE/3G WAN loses its device: vnstatd drops the
# traffic it counted since its last read (up to an UpdateInterval) and
# flushes the rest, the held live delta (_live_delta) is re-anchored on that
# flush, and usage reads quota + overshoot - loss, often below the quota on
# a busy link: raised, crossed again within a poll, cut again, over-quota
# traffic leaking on each round. So while the quota is enforced, the highest
# usage seen is kept here, on tmpfs, and the quota is checked against it. It
# belongs to the period, the baseline and the interfaces it was measured
# with: a new period, a reset or another member list starts over. Raising
# the quota still lifts the cut, and the mark goes with the enforcement.
# Keep in sync with the read-only twin in the rpcd plugin.
#
# _hwm_tag <baseline rx> <baseline tx>: what the mark belongs to.
_hwm_tag() {
	# word splitting on purpose: one comma-joined token, whatever the spacing
	# shellcheck disable=SC2086
	printf '%s;%s;%s;%s' "$(_baseline_period)" "$1" "$2" "$(echo $target_interfaces | tr ' ' ',')"
}

# _hwm_apply: raise rx/tx to the mark of this period, drop a mark of another.
# Sets _hwm_tag_now (the tag, when computed) and _hwm_stored ("<rx> <tx>"
# of a usable mark) for _hwm_record.
_hwm_apply() {
	local tag hrx htx
	_hwm_tag_now=""
	_hwm_stored=""
	[ -f "$_hwm_file" ] || return 0
	read -r tag hrx htx < "$_hwm_file"
	_hwm_tag_now="$(_hwm_tag "$_b_rx0" "$_b_tx0")"
	case "${hrx:-x}${htx:-x}" in
		*[!0-9]*) tag="" ;;
	esac
	if [ "$tag" != "$_hwm_tag_now" ]; then
		rm -f "$_hwm_file"
		return 0
	fi
	[ "$rx" -lt "$hrx" ] && rx=$hrx
	[ "$tx" -lt "$htx" ] && tx=$htx
	_hwm_stored="$hrx $htx"
}

# _hwm_record: keep rx/tx as the mark (written only when it moves).
_hwm_record() {
	[ "$_hwm_stored" = "$rx $tx" ] && return 0
	[ -n "$_hwm_tag_now" ] || _hwm_tag_now="$(_hwm_tag "$_b_rx0" "$_b_tx0")"
	mkdir -p "$_TSTATE_DIR"
	printf '%s %s %s' "$_hwm_tag_now" "$rx" "$tx" > "${_hwm_file}.new" && \
		mv "${_hwm_file}.new" "$_hwm_file"
}

# _lift_throttle <interfaces>: remove the throttle this daemon applied, if any.
_lift_throttle() {
	local iface real_iface
	[ -f "$_tstate_file" ] || return 0
	logger -t "OMR-QUOTA" "Removing throttle from $1"
	for iface in $1; do
		real_iface="$(_get_real_interface "$iface")"
		[ -n "$real_iface" ] && _remove_throttle "$real_iface" "$iface"
	done
	rm -f "$_tstate_file"
}

# _raise_cut_interfaces: raise back exactly what this daemon took down --
# not every interface the quota covers. <id>.ifdown is that list; a lone
# <id>.cut is a marker from a daemon older than it, which recorded the whole
# target list, so honour that once rather than leaving an upgraded router's
# interface down for good.
_raise_cut_interfaces() {
	local iface up_list=""
	if [ -f "$_ifdown_file" ]; then
		up_list="$(cat "$_ifdown_file")"
	elif [ -f "$_cut_file" ]; then
		up_list="$(cat "$_cut_file")"
		up_list="${up_list:-${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}}"
	fi
	for iface in $up_list; do
		if [ "$(ifstatus "$iface" | jsonfilter -e '@.up')" = "false" ]; then
			logger -t "OMR-QUOTA" "Set interface $iface up"
			ifup $iface
		fi
	done
	rm -f "$_cut_file" "$_ifdown_file"
}

# _valid_wan_besides <interfaces>: some WAN other than these still carries
# traffic -- multipath not off, up in netifd, up for omr-tracker, and not cut
# by a quota. A WAN up in netifd but dead for the tracker does not count:
# keeping it as the last one would leave the router with no working WAN.
_valid_wan_besides() {
	local excluded marker entry iface multipath network interfaces
	excluded=" $* "
	for marker in "$_TSTATE_DIR"/*.cut; do
		[ -f "$marker" ] || continue
		entry="${marker##*/}"
		excluded="$excluded${entry%.cut} $(cat "$marker" 2>/dev/null) "
	done
	network="$(uci -q show network)"
	# interface sections only: network.globals has a multipath option too
	interfaces=" $(printf '%s\n' "$network" | sed -n "s/^network\.\([^.=]*\)=interface\$/\1/p" | tr '\n' ' ')"
	for entry in $(printf '%s\n' "$network" | sed -n "s/^network\.\([^.]*\)\.multipath='\{0,1\}\([a-z]*\)'\{0,1\}\$/\1=\2/p"); do
		iface="${entry%%=*}"
		multipath="${entry#*=}"
		case "$interfaces" in *" $iface "*) ;; *) continue ;; esac
		case "$multipath" in ''|off) continue ;; esac
		case "$excluded" in *" $iface "*) continue ;; esac
		[ "$(uci -q -P "${OMR_STATE_DIR:-/var/state}" get "openmptcprouter.${iface}.state")" = "up" ] || continue
		[ "$(ifstatus "$iface" | jsonfilter -q -e '@.up')" = "true" ] || continue
		return 0
	done
	return 1
}

_prev_exceeded=-1
_keep_last_mode=""
last_calculation=0
last_tt=0

# main loop
while true; do
	# The interfaces a quota meters are also the interfaces it enforces on:
	# a per-interface quota's list is just itself, a global quota's list is
	# every interface it combines -- so exceeding it cuts/throttles all of them.
	target_interfaces="${OMR_QUOTA_INTERFACES:-$OMR_QUOTA_INTERFACE}"

	rx=0
	tx=0
	usage_complete=1
	for iface in $target_interfaces; do
		usage="$(_interface_usage "$iface")"
		usage_rest="${usage#* }"
		rxi="${usage%% *}"
		txi="${usage_rest%% *}"
		[ "${usage_rest##* }" = "1" ] || usage_complete=0
		rx=$(( rx + ${rxi:-0} ))
		tx=$(( tx + ${txi:-0} ))
	done
	_baseline="$(_read_baseline)"
	_b_rx0="${_baseline%% *}"; _b_tx0="${_baseline##* }"
	rx=$(( rx - _b_rx0 ))
	tx=$(( tx - _b_tx0 ))
	[ "$rx" -lt 0 ] && rx=0
	[ "$tx" -lt 0 ] && tx=0
	_hwm_apply
	tt=$(( rx + tx ))
	cb=""

	exceeded=0
	reason=""
	# quota_reached: a volume quota itself was crossed. Only that may set
	# the persistent marker below: a daily-budget trip or an unreadable
	# sample are transient, and once recorded as persistent they kept the
	# WAN cut for good, until a manual reset.
	quota_reached=0
	if [ -n "$OMR_QUOTA_RX" ] && [ "$OMR_QUOTA_RX" -gt 0 ] && [ -n "$rx" ] && [ "$OMR_QUOTA_RX" -le "$rx" ]; then
		exceeded=1; quota_reached=1; reason="RX quota"
	elif [ -n "$OMR_QUOTA_TX" ] && [ "$OMR_QUOTA_TX" -gt 0 ] && [ -n "$tx" ] && [ "$OMR_QUOTA_TX" -le "$tx" ]; then
		exceeded=1; quota_reached=1; reason="TX quota"
	elif [ -n "$OMR_QUOTA_TT" ] && [ "$OMR_QUOTA_TT" -gt 0 ] && [ -n "$tt" ] && [ "$OMR_QUOTA_TT" -le "$tt" ]; then
		exceeded=1; quota_reached=1; reason="RX+TX quota"
	fi
	# Once enforcement is active, an unreadable/incomplete vnstat sample must
	# not be interpreted as a usage reset. Keep the current cut/throttle until
	# a complete sample proves that the quota is no longer exceeded.
	if [ "$usage_complete" != "1" ] && { [ "$_prev_exceeded" = "1" ] || [ -f "$_cut_file" ] || [ -f "$_tstate_file" ] || [ -f "$_blocklan_file" ]; }; then
		exceeded=1
		[ -z "$reason" ] && reason="usage counters temporarily unavailable"
	fi

	_calculate_budget_limit "$tt"
	if [ -n "$cv" ] && [ "$(( tt - last_tt ))" -gt "$cv" ]; then
		exceeded=1; reason="daily budget"
	fi

	# For persistent scope: once exceeded, stay exceeded even across month boundaries
	if [ "${OMR_QUOTA_SCOPE:-month_only}" = "persistent" ] && [ -f "$_persist_file" ]; then
		exceeded=1
		[ -z "$reason" ] && reason="persistent exceeded state"
	fi

	_throttle_devs=""
	if [ "$exceeded" = "1" ]; then
		# Record persistent exceeded state on first detection
		if [ "$quota_reached" = "1" ] && [ "${OMR_QUOTA_SCOPE:-month_only}" = "persistent" ] && [ ! -f "$_persist_file" ]; then
			mkdir -p "$_PERSIST_DIR"
			touch "$_persist_file"
		fi

		# cut_keep_last: cut while another WAN is valid, otherwise keep this
		# one up and block the LAN, so the router never loses its last WAN
		# (and the remote management riding it). Two quotas exceeded at the
		# same poll would each see the other WAN still up and both cut: the
		# lock makes the decision and the cut marker it writes one daemon at
		# a time, so the second one finds the first already cut.
		action="${OMR_QUOTA_ACTION:-cut}"
		if [ "$action" = "cut_keep_last" ]; then
			mkdir -p "$_TSTATE_DIR"
			exec 9>"${_TSTATE_DIR}/keep-last.lock"
			flock -x 9
			if _valid_wan_besides $target_interfaces; then
				action="cut"
			else
				action="block"
				[ "$_keep_last_mode" != "block" ] && \
					logger -t "OMR-QUOTA" "$target_interfaces: no other valid WAN, kept up with the LAN blocked: $reason reached"
			fi
			_keep_last_mode="$action"
		fi

		if [ "$action" = "throttle" ]; then
			[ "$_prev_exceeded" != "1" ] && \
				logger -t "OMR-QUOTA" "Throttling $target_interfaces to ${OMR_QUOTA_THROTTLE_DL:-1}/${OMR_QUOTA_THROTTLE_UL:-1} Mbps: $reason reached"
			mkdir -p "$_TSTATE_DIR"
			printf '%s' "$target_interfaces" > "$_tstate_file"
			for iface in $target_interfaces; do
				# Keep the interface up while throttled -- and bring it up
				# *before* shaping it, see _wait_iface_up.
				iface_up="$(ifstatus "$iface" | jsonfilter -e '@.up')"
				if [ "$iface_up" = "false" ]; then
					ifup $iface
					_wait_iface_up "$iface"
				fi
				real_iface="$(_get_real_interface "$iface")"
				[ -n "$real_iface" ] && _apply_throttle "$real_iface"
				_throttle_devs="$_throttle_devs $real_iface"
			done
			# a cut quota switched to throttle: its interfaces are up again,
			# so nothing is owed an ifup any more, nor a LAN block
			rm -f "$_cut_file" "$_ifdown_file"
			_release_lan_block
		elif [ "$action" = "block" ]; then
			_block_lan "$reason reached"
			# A quota switched from throttle or cut: the WAN(s) must carry
			# the router's own traffic at full speed again.
			_lift_throttle "$target_interfaces"
			_raise_cut_interfaces
			# Nor a daily-budget limit (method 2): past the quota the
			# remaining volume is 0, so it shaped the WAN kept up for the
			# router to 1 kbit/s, the LAN being blocked anyway.
			cb=""
		else
			# cut: bring the interface(s) down
			if [ "${OMR_QUOTA_BLOCK_LAN:-0}" = "1" ]; then
				_block_lan "$reason reached"
			else
				_release_lan_block
			fi
			mkdir -p "$_TSTATE_DIR"
			printf '%s' "$target_interfaces" > "$_cut_file"
			# Cumulative, not "what went down this poll": from the second
			# poll on they are all already down and that would read empty.
			ifdown_list=""
			[ -f "$_ifdown_file" ] && ifdown_list="$(cat "$_ifdown_file")"
			for iface in $target_interfaces; do
				iface_up="$(ifstatus "$iface" | jsonfilter -e '@.up')"
				if [ "$iface_up" = "true" ]; then
					logger -t "OMR-QUOTA" "Set interface $iface down: $reason reached"
					ifdown $iface
					case " $ifdown_list " in
						*" $iface "*) ;;
						*) ifdown_list="${ifdown_list:+$ifdown_list }$iface" ;;
					esac
				fi
			done
			# Left absent rather than written empty when the quota cut
			# nothing itself (every interface it covers was already down):
			# absent means "owes no ifup", which is exactly the case.
			[ -n "$ifdown_list" ] && printf '%s' "$ifdown_list" > "$_ifdown_file"
		fi
		[ "${OMR_QUOTA_ACTION:-cut}" = "cut_keep_last" ] && exec 9>&-
	else
		_keep_last_mode=""
		# Quota not exceeded — remove any throttle that was active
		_lift_throttle "$target_interfaces"
		_raise_cut_interfaces
		_release_lan_block
	fi
	if [ "$exceeded" = "1" ]; then
		_hwm_record
	elif [ -f "$_hwm_file" ]; then
		rm -f "$_hwm_file"
	fi

	# After the enforcement above, not before: a throttle lifted this poll
	# unshapes its devices, and the budget limit then goes back on them in
	# the same poll instead of one interval later.
	if [ -n "$cb" ]; then
		_apply_downstream_limit "$cb"
	else
		_remove_downstream_limit
	fi

	_prev_exceeded=$exceeded
	sleep "${OMR_QUOTA_INTERVAL:-60}"
done
