#!/usr/bin/bash
#
# webstats.sh -- inkrementelle GoAccess-Monatsreports pro Site
#
# Aufruf:   webstats.sh <site-name> [goaccess-log-format]
# Beispiel: webstats.sh www.kunde-x.de
#           webstats.sh www.kunde-x.de 'VCOMBINED'
#
# Liest <LOG_DIR>/<site>.access_log (+ rotierte Generationen) inkrementell
# per Inode+Offset-Tracking, splittet die neuen Zeilen nach Monat und
# akkumuliert sie in GoAccess-Persist-DBs (eine pro Monat). Ausgabe sind
# statische Monats-HTMLs plus generierter Index.
#
# Eigenschaften:
#   - kein Daemon, keine SQL-DB, exactly-once pro Logzeile
#   - logrotate bleibt unangetastet (weekly, dateext, rotate 4, create)
#   - Rotation (rename), Truncate und (defensiv) gz-Kompression werden erkannt
#   - transaktional: DB-Backup vor dem Lauf, Rollback bei Fehler,
#     State wird erst nach Erfolg geschrieben
#
# Bei eigenem LogFormat ggf. zusaetzlich setzen:
#   DATE_FORMAT='%d/%b/%Y' TIME_FORMAT='%H:%M:%S' webstats.sh <site> '<format>'

set -euo pipefail

# --- Argumente --------------------------------------------------------------

SITE="${1:?Usage: webstats.sh <site-name> [goaccess-log-format]}"
LOG_FORMAT="${2:-COMBINED}"

# --- Konfiguration ----------------------------------------------------------

LOG_DIR="${LOG_DIR:-/var/log/httpd}"          # amici: webserver_logdir
BASE_DIR="${BASE_DIR:-/var/tmp/webstats}"     # amici: interfacedir/var/lib/webstats
GOACCESS_BIN="${GOACCESS_BIN:-goaccess}"

# Apex ohne fuehrendes "www." -- deckt als Self-Referrer die Hauptdomain und
# alle Subdomains (inkl. www.) ab, egal ob die Site mit oder ohne www. laeuft.
APEX="${SITE#www.}"

# Zusaetzliche GoAccess-Optionen bei Bedarf, z.B.:
#   --anonymize-ip        (DSGVO: IPs auch in der Aggregat-DB gekuerzt)
#   --ignore-crawlers     (Bots ausblenden)
# ACHTUNG: --anonymize-ip / --no-query-string / --ignore-crawlers sind
# irreversible Parse-Zeit-Entscheidungen (in der persist-DB einbetoniert) --
# das gesamte Set vor dem ersten Produktivlauf final festlegen.
# set -f schuetzt das '*.'-Referrer-Pattern vor Pathname-Expansion beim
# Array-Aufbau; Wortsplitting (fuer den Env-Override) bleibt erhalten.
set -f
GOACCESS_EXTRA=(${GOACCESS_EXTRA:---anonymize-ip --http-protocol=no --ignore-crawlers --ignore-referrer=${APEX} --ignore-referrer=*.${APEX}})
set +f

# ---

ACTIVE="$LOG_DIR/${SITE}.access_log"
STATE_DIR="$BASE_DIR/state"
STATE_FILE="$STATE_DIR/${SITE}.state"
DB_SITE="$BASE_DIR/db/${SITE}"
DB_BAK="$BASE_DIR/db/${SITE}.bak"
OUT_SITE="$BASE_DIR/html/${SITE}"

log()  { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
die()  { log "FEHLER: $*" >&2; exit 1; }

command -v "$GOACCESS_BIN" >/dev/null || die "goaccess nicht gefunden"
[ -e "$ACTIVE" ] || die "aktives Log fehlt: $ACTIVE"

mkdir -p "$STATE_DIR" "$DB_SITE" "$OUT_SITE"

# --- Lock (ein Lauf pro Site) -----------------------------------------------

exec 9>"$STATE_DIR/${SITE}.lock"
flock -n 9 || die "laeuft bereits (Lock: $STATE_DIR/${SITE}.lock)"

# --- Arbeitsverzeichnis -----------------------------------------------------

TMPDIR_WS="$(mktemp -d)"
CHUNK="$TMPDIR_WS/chunk"
: > "$CHUNK"
cleanup() { rm -rf "$TMPDIR_WS"; }
trap cleanup EXIT

# --- Hilfsfunktionen --------------------------------------------------------

mon2num() {
    case "$1" in
        Jan) echo 01;; Feb) echo 02;; Mar) echo 03;; Apr) echo 04;;
        May) echo 05;; Jun) echo 06;; Jul) echo 07;; Aug) echo 08;;
        Sep) echo 09;; Oct) echo 10;; Nov) echo 11;; Dec) echo 12;;
        *) die "unbekannter Monat: $1";;
    esac
}

# Datei zum gespeicherten Inode finden (aktiv oder rotiert, ggf. .gz)
locate_inode() {
    find "$LOG_DIR" -maxdepth 1 -inum "$1" \
        \( -name "${SITE}.access_log" -o -name "${SITE}.access_log[-.]*" \) \
        -print -quit 2>/dev/null || true
}

# 8-stelliges Datums-Suffix (dateext) aus einem rotierten Dateinamen
datesuffix() {
    local b="${1##*/}"; b="${b%.gz}"
    [[ "$b" =~ ([0-9]{8})$ ]] && echo "${BASH_REMATCH[1]}" || echo ""
}

# Alle rotierten Generationen, nach Datums-Suffix aufsteigend
list_rotated() {
    local f
    for f in "$LOG_DIR/${SITE}.access_log"[-.]*; do
        [ -e "$f" ] || continue
        echo "$f"
    done | sort
}

# Kompletten Rest einer (abgeschlossenen) Datei ab Offset anhaengen.
# Rotierte Dateien enden mit \n -- kein Trimmen noetig.
slice_file() {
    local f="$1" off="$2"
    case "$f" in
        *.gz) zcat -- "$f" | tail -c +"$((off + 1))" >> "$CHUNK" ;;
        *)    tail -c +"$((off + 1))" -- "$f" >> "$CHUNK" ;;
    esac
}

# Aktive Datei ab Offset lesen, unvollstaendige letzte Zeile zuruecklassen.
# Setzt NEW_INODE/NEW_OFFSET (= naechster State).
slice_active() {
    local off="$1" size part
    size=$(stat -c%s -- "$ACTIVE")
    if (( size < off )); then
        log "WARNUNG: $ACTIVE kleiner als Offset ($size < $off) -- Truncate erkannt, lese ab 0"
        off=0
    fi
    tail -c +"$((off + 1))" -- "$ACTIVE" > "$TMPDIR_WS/active"
    if [ -s "$TMPDIR_WS/active" ] && \
       [ "$(tail -c1 -- "$TMPDIR_WS/active" | wc -l)" -eq 0 ]; then
        part=$(tail -n1 -- "$TMPDIR_WS/active" | wc -c)
        truncate -s -"$part" -- "$TMPDIR_WS/active"
    fi
    NEW_OFFSET=$(( off + $(stat -c%s -- "$TMPDIR_WS/active") ))
    NEW_INODE=$(stat -c%i -- "$ACTIVE")
    cat "$TMPDIR_WS/active" >> "$CHUNK"
}

# --- Neue Logzeilen einsammeln ----------------------------------------------

NEW_INODE="" NEW_OFFSET=0

if [ -s "$STATE_FILE" ]; then
    read -r ST_INODE ST_OFFSET ST_NAME < "$STATE_FILE"
    FOUND="$(locate_inode "$ST_INODE")"

    if [ -z "$FOUND" ]; then
        # Datei ausserhalb der Retention geloescht (Script lag >4 Wochen brach):
        # alles Vorhandene ist sicher unverarbeitet -> komplett aufholen.
        log "WARNUNG: Inode $ST_INODE ($ST_NAME) nicht mehr vorhanden -- verarbeite alle vorhandenen Logs neu"
        while IFS= read -r f; do slice_file "$f" 0; done < <(list_rotated)
        slice_active 0
    elif [ "$FOUND" = "$ACTIVE" ]; then
        # Keine Rotation seit dem letzten Lauf: einfach weiterlesen.
        slice_active "$ST_OFFSET"
    else
        # Rotation: Rest der alten Datei, dann evtl. dazwischenliegende
        # Generationen (Script lag >1 Woche brach), dann aktive Datei ab 0.
        log "Rotation erkannt: $ST_NAME -> ${FOUND##*/}"
        slice_file "$FOUND" "$ST_OFFSET"
        REF="$(datesuffix "$FOUND")"
        while IFS= read -r f; do
            [ "$f" = "$FOUND" ] && continue
            SUF="$(datesuffix "$f")"
            if [ -n "$REF" ] && [ -n "$SUF" ] && [ "$SUF" \> "$REF" ]; then
                log "hole verpasste Generation nach: ${f##*/}"
                slice_file "$f" 0
            fi
        done < <(list_rotated)
        slice_active 0
    fi
else
    # Erstlauf: vorhandene Historie (max. 4 Wochen) mitnehmen, dann aktiv.
    log "Erstlauf fuer $SITE -- importiere vorhandene rotierte Logs"
    while IFS= read -r f; do slice_file "$f" 0; done < <(list_rotated)
    slice_active 0
fi

if [ ! -s "$CHUNK" ]; then
    log "keine neuen Zeilen fuer $SITE"
    printf '%s %s %s\n' "$NEW_INODE" "$NEW_OFFSET" "${ACTIVE##*/}" > "$STATE_FILE.tmp"
    mv -- "$STATE_FILE.tmp" "$STATE_FILE"
    exit 0
fi
log "$(wc -l < "$CHUNK") neue Zeilen fuer $SITE"

# --- Transaktion: DB sichern, fuettern, bei Fehler zurueckrollen -------------

rm -rf -- "$DB_BAK"
cp -a -- "$DB_SITE" "$DB_BAK"

rollback() {
    log "FEHLER im GoAccess-Lauf -- rolle DB zurueck, State bleibt unveraendert" >&2
    rm -rf -- "$DB_SITE"
    mv -- "$DB_BAK" "$DB_SITE"
    cleanup
}
trap rollback ERR

# Vorkommende Monate ermitteln ("Jul/2026" ...)
mapfile -t STAMPS < <(grep -oE '\[[0-9]{2}/[A-Za-z]{3}/[0-9]{4}' "$CHUNK" \
                      | cut -d/ -f2,3 | sort -u)
[ "${#STAMPS[@]}" -gt 0 ] || die "keine parsebaren Datumsstempel im Chunk -- LogFormat pruefen"

FMT_OPTS=(--log-format="$LOG_FORMAT")
[ -n "${DATE_FORMAT:-}" ] && FMT_OPTS+=(--date-format="$DATE_FORMAT")
[ -n "${TIME_FORMAT:-}" ] && FMT_OPTS+=(--time-format="$TIME_FORMAT")

for S in "${STAMPS[@]}"; do
    MON="${S%%/*}"; YEAR="${S##*/}"
    YM="${YEAR}-$(mon2num "$MON")"
    DB="$DB_SITE/$YM"
    mkdir -p -- "$DB"

    RESTORE=()
    [ -n "$(ls -A -- "$DB" 2>/dev/null)" ] && RESTORE=(--restore)

    log "verarbeite $YM"
    grep -E "\[[0-9]{2}/${MON}/${YEAR}:" "$CHUNK" | \
        "$GOACCESS_BIN" - \
            --no-global-config --no-parsing-spinner --no-progress \
            "${FMT_OPTS[@]}" \
            "${GOACCESS_EXTRA[@]}" \
            --persist "${RESTORE[@]}" --db-path="$DB" \
            -o "$OUT_SITE/$YM.html"
done

# Jahres-Aggregat: gleiche Zeilen zusaetzlich in eine DB pro Jahr
# (korrekte Unique-Visitor-Deduplikation auf Jahresebene)
mapfile -t YEARS < <(printf '%s\n' "${STAMPS[@]}" | cut -d/ -f2 | sort -u)
for YEAR in "${YEARS[@]}"; do
    DB="$DB_SITE/$YEAR"
    mkdir -p -- "$DB"

    RESTORE=()
    [ -n "$(ls -A -- "$DB" 2>/dev/null)" ] && RESTORE=(--restore)

    log "verarbeite Jahres-Summary $YEAR"
    grep -E "\[[0-9]{2}/[A-Za-z]{3}/${YEAR}:" "$CHUNK" | \
        "$GOACCESS_BIN" - \
            --no-global-config --no-parsing-spinner --no-progress \
            "${FMT_OPTS[@]}" \
            "${GOACCESS_EXTRA[@]}" \
            --persist "${RESTORE[@]}" --db-path="$DB" \
            -o "$OUT_SITE/$YEAR.html"
done

# --- Erfolg: State schreiben, Backup verwerfen, Index bauen ------------------

trap cleanup EXIT
trap - ERR

printf '%s %s %s\n' "$NEW_INODE" "$NEW_OFFSET" "${ACTIVE##*/}" > "$STATE_FILE.tmp"
mv -- "$STATE_FILE.tmp" "$STATE_FILE"
rm -rf -- "$DB_BAK"

{
    printf '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">\n'
    printf '<title>Webstatistik %s</title>\n' "$SITE"
    printf '<style>body{font:15px/1.5 sans-serif;max-width:40em;margin:3em auto}li{margin:.3em 0}</style>\n'
    printf '</head><body><h1>Webstatistik %s</h1><ul>\n' "$SITE"
    for f in $(ls -1 "$OUT_SITE"/[0-9][0-9][0-9][0-9].html \
                     "$OUT_SITE"/[0-9][0-9][0-9][0-9]-[0-9][0-9].html 2>/dev/null | sort -r); do
        b="${f##*/}"
        printf '<li><a href="%s">%s</a> <small>(Stand: %s)</small></li>\n' \
               "$b" "${b%.html}" "$(date -r "$f" '+%F %T')"
    done
    printf '</ul><p><small>Generiert: %s</small></p></body></html>\n' "$(date '+%F %T')"
} > "$OUT_SITE/index.html.tmp"
mv -- "$OUT_SITE/index.html.tmp" "$OUT_SITE/index.html"

log "fertig: $OUT_SITE"
