#!/bin/bash
##
# Linux Malware Detect v2.0.1
#             (C) 2002-2026, R-fx Networks <proj@rfxn.com>
#             (C) 2026, Ryan MacDonald <ryan@rfxn.com>
# This program may be freely redistributed under the terms of the GNU GPL v2
##
# tlog — standalone CLI wrapper for tlog_lib.sh (incremental log reading; delegates to tlog_lib.sh)

PATH=/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin:/usr/local/sbin
export PATH

TLOG_VERSION="2.0.6"

# BASERUN: cursor storage directory — sed-replaced at install time
BASERUN="${BASERUN:-/tmp}"

# Help and version functions (pre-source — no library dependency)
_tlog_version_banner() {
	cat <<-EOF
	tlog $TLOG_VERSION
	Copyright (C) 2002-2026 R-fx Networks <proj@rfxn.com>
	                        Ryan MacDonald <ryan@rfxn.com>
	License: GNU GPL v2
	EOF
}

_tlog_usage_short() {
	cat <<-EOF
	tlog $TLOG_VERSION — incremental log reader

	usage: tlog [OPTIONS] <file> <tlog_name> [mode]
	       tlog [OPTIONS] --full <file> [max_lines]
	       tlog [OPTIONS] --status <name> [file]
	       tlog [OPTIONS] --reset <name>
	       tlog [OPTIONS] --adjust <name> <delta>
	       tlog -v | --version
	       tlog -h | --help

	Options:
	  -m, --mode MODE         Tracking mode: bytes (default) or lines
	  -b, --baserun DIR       Cursor storage directory (default: $BASERUN)
	  -f, --flock             Enable flock-based cursor locking
	  --first-run skip|full   First-run behavior (default: skip)
	  -v, --version           Show version and exit
	  -h                      Show this help and exit
	  --help                  Show detailed help with examples

	Subcommands:
	  --full <file> [max]     Read entire file without cursor tracking
	  --status <name> [file]  Display cursor state for <name>
	  --reset <name>          Delete cursor and related files for <name>
	  --adjust <name> <delta> Subtract <delta> from stored cursor

	Exit codes: 0=success, 1=error, 2=cursor corrupt, 3=journal N/A, 4=lock failed
	EOF
}

_tlog_usage_long() {
	_tlog_usage_short
	cat <<-'EOF'

	DESCRIPTION
	  tlog reads new content from a growing log file since the last invocation,
	  using a cursor file to track position. It handles log rotation, compressed
	  rotated files, and optional systemd journal fallback.

	  The default command reads incrementally: first run records position and
	  outputs nothing (or the entire file with --first-run full). Subsequent
	  runs output only new content.

	TRACKING MODES
	  bytes   Track byte offset; read via tail -c. Default. Best for grep/awk.
	  lines   Track line count; read via tail -n. Best for digests and reports.

	  Mode priority: positional arg > -m flag > TLOG_MODE env > bytes default.

	SUBCOMMANDS
	  --full <file> [max_lines]
	      Output the entire file (or last max_lines lines) without cursor
	      tracking. Does not require BASERUN. Useful for one-shot scans.

	  --status <name> [file]
	      Display cursor state: path, mode, value, and age. If file is given,
	      also shows current file size and pending delta. Read-only — does not
	      modify any files. Exits 0 even for corrupt or uninitialized cursors.

	  --reset <name>
	      Delete cursor, .jts (journal timestamp), .lock, and any orphaned
	      temp files matching .<name>.* in the BASERUN directory. Reports
	      each deletion. Always exits 0.

	  --adjust <name> <delta>
	      Subtract delta from the stored cursor value. Used after in-place
	      log trimming. Mode-aware: adjusts bytes or lines automatically.
	      Clamps to zero on over-subtraction.

	ENVIRONMENT VARIABLES
	  TLOG_MODE        Default tracking mode (bytes or lines)
	  TLOG_FLOCK       Set to 1 to enable cursor locking (or use -f)
	  TLOG_FIRST_RUN   First-run behavior: skip (default) or full
	  BASERUN          Cursor storage directory (or use -b)
	  LOG_SOURCE       Set to "file" to disable journal fallback

	EXAMPLES
	  # Incremental read (bytes mode, default)
	  tlog /var/log/auth.log auth

	  # Incremental read with line-count tracking
	  tlog -m lines /var/log/mail.log mail

	  # Read with flock and custom baserun
	  tlog -f -b /opt/myapp/tmp /var/log/syslog syslog

	  # First run outputs entire file
	  tlog --first-run full /var/log/app.log app

	  # Full file read (no cursor)
	  tlog --full /var/log/syslog
	  tlog --full /var/log/syslog 500

	  # Check cursor state
	  tlog --status syslog
	  tlog --status syslog /var/log/syslog

	  # Reset tracking for a log
	  tlog --reset auth

	  # Adjust cursor after trimming 4096 bytes from top of log
	  tlog --adjust mylog 4096
	EOF
}

_CMD="read"
_OPT_MODE=""
_OPT_BASERUN=""
_OPT_FLOCK=""
_OPT_FIRST_RUN=""
_ARGS=()

while [[ $# -gt 0 ]]; do
	case "$1" in
		-v|--version)
			_tlog_version_banner
			exit 0
			;;
		-h)
			_tlog_usage_short
			exit 0
			;;
		--help)
			_tlog_usage_long
			exit 0
			;;
		-m|--mode)
			if [[ -z "${2:-}" ]]; then
				echo "tlog: $1 requires a value (bytes or lines)" >&2
				exit 1
			fi
			_OPT_MODE="$2"
			shift 2
			;;
		-b|--baserun)
			if [[ -z "${2:-}" ]]; then
				echo "tlog: $1 requires a directory path" >&2
				exit 1
			fi
			_OPT_BASERUN="$2"
			shift 2
			;;
		-f|--flock)
			_OPT_FLOCK="1"
			shift
			;;
		--first-run)
			if [[ -z "${2:-}" ]]; then
				echo "tlog: --first-run requires a value (skip or full)" >&2
				exit 1
			fi
			_OPT_FIRST_RUN="$2"
			shift 2
			;;
		--full)
			_CMD="full"
			shift
			;;
		--status)
			_CMD="status"
			shift
			;;
		--reset)
			_CMD="reset"
			shift
			;;
		--adjust)
			_CMD="adjust"
			shift
			;;
		--)
			shift
			while [[ $# -gt 0 ]]; do
				_ARGS+=("$1")
				shift
			done
			break
			;;
		-*)
			echo "tlog: unknown option: $1" >&2
			exit 1
			;;
		*)
			_ARGS+=("$1")
			shift
			;;
	esac
done

# No arguments and no subcommand → show usage
if [[ ${#_ARGS[@]} -eq 0 ]] && [[ "$_CMD" == "read" ]]; then
	_tlog_usage_short >&2
	exit 1
fi

_SCRIPT_DIR="${0%/*}"
[[ "$_SCRIPT_DIR" == "$0" ]] && _SCRIPT_DIR="."
_LIB="${_SCRIPT_DIR}/tlog_lib.sh"

if [[ ! -f "$_LIB" ]]; then
	echo "tlog: library not found: $_LIB" >&2
	exit 1
fi

# Security check: library must be root-owned and not world-writable
_lib_owner=$(stat -L -c '%u' "$_LIB" 2>/dev/null)  # stat may fail if lib deleted between checks
_lib_perms=$(stat -L -c '%a' "$_LIB" 2>/dev/null)  # stat may fail if lib deleted between checks
_lib_world="${_lib_perms: -1}"
if [[ "$_lib_owner" != "0" ]] || [[ $((_lib_world & 2)) -ne 0 ]]; then
	echo "tlog: security check failed on $_LIB (owner=$_lib_owner perms=$_lib_perms)" >&2
	exit 1
fi

# Source the library
# shellcheck disable=SC1090,SC1091
. "$_LIB"

# Version cross-check: CLI wrapper and library must be from same release
# shellcheck disable=SC2154
if [[ "$TLOG_VERSION" != "$TLOG_LIB_VERSION" ]]; then
	echo "tlog: warning: version mismatch (tlog=$TLOG_VERSION, lib=$TLOG_LIB_VERSION)" >&2
fi

if [[ -n "$_OPT_BASERUN" ]]; then
	BASERUN="$_OPT_BASERUN"
fi

if [[ -n "$_OPT_MODE" ]]; then
	if [[ "$_OPT_MODE" != "bytes" ]] && [[ "$_OPT_MODE" != "lines" ]]; then
		echo "tlog: invalid mode '$_OPT_MODE' (must be 'bytes' or 'lines')" >&2
		exit 1
	fi
	TLOG_MODE="$_OPT_MODE"
	export TLOG_MODE
fi

if [[ -n "$_OPT_FLOCK" ]]; then
	TLOG_FLOCK="1"
	export TLOG_FLOCK
fi

if [[ -n "$_OPT_FIRST_RUN" ]]; then
	if [[ "$_OPT_FIRST_RUN" != "skip" ]] && [[ "$_OPT_FIRST_RUN" != "full" ]]; then
		echo "tlog: invalid first-run mode '$_OPT_FIRST_RUN' (must be 'skip' or 'full')" >&2
		exit 1
	fi
	TLOG_FIRST_RUN="$_OPT_FIRST_RUN"
	export TLOG_FIRST_RUN
fi

# Warn when running as root with BASERUN=/tmp (cursor-using commands only)
if [[ "$_CMD" != "full" ]] && [[ "$(id -u)" -eq 0 ]] && [[ "$BASERUN" == "/tmp" ]]; then
	echo "tlog: warning: BASERUN is /tmp; cursor files stored in world-writable directory" >&2
	echo "tlog: warning: set -b/--baserun or BASERUN env to a secure path" >&2
fi

_tlog_require_baserun() {
	if [[ ! -d "$BASERUN" ]]; then
		echo "tlog: baserun directory not found: $BASERUN" >&2
		exit 1
	fi
}

# shellcheck disable=SC2154
_tlog_cmd_status() {
	local name="${_ARGS[0]:-}"
	local file="${_ARGS[1]:-}"

	if [[ -z "$name" ]]; then
		echo "tlog: --status requires a cursor name" >&2
		exit 1
	fi

	_tlog_validate_name "$name" || exit 1
	_tlog_require_baserun

	local cursor_file="$BASERUN/$name"

	printf 'cursor: %s\n' "$cursor_file"

	# Parse cursor
	_tlog_parse_cursor "$name" "$BASERUN"
	local parse_rc=$?

	if [[ $parse_rc -eq 2 ]]; then
		# Corrupt cursor: show raw content for diagnosis
		local raw_value=""
		read -r raw_value < "$cursor_file" 2>/dev/null || true  # read exits 1 on EOF; not an error
		printf 'raw:    %s\n' "$raw_value"
		printf 'state:  corrupt (would auto-reset on next read)\n'
	elif [[ -z "$_tlog_cursor_value" ]]; then
		printf 'state:  not initialized\n'
	else
		printf 'mode:   %s\n' "$_tlog_cursor_mode"
		printf 'value:  %s\n' "$_tlog_cursor_value"
		printf 'state:  valid\n'

		# Cursor age
		if [[ -f "$cursor_file" ]]; then
			local mtime now age_s
			mtime=$(stat -c %Y "$cursor_file" 2>/dev/null) || mtime=0  # stat fails if cursor removed; default to 0
			now=$(date +%s)
			age_s=$((now - mtime))
			if [[ $age_s -ge 86400 ]]; then
				printf 'age:    %dd %dh\n' "$((age_s / 86400))" "$(((age_s % 86400) / 3600))"
			elif [[ $age_s -ge 3600 ]]; then
				printf 'age:    %dh %dm\n' "$((age_s / 3600))" "$(((age_s % 3600) / 60))"
			else
				printf 'age:    %dm %ds\n' "$((age_s / 60))" "$((age_s % 60))"
			fi
		fi
	fi

	# File info if provided
	if [[ -n "$file" ]] && [[ -f "$file" ]]; then
		local file_bytes file_lines
		file_bytes=$(tlog_get_file_size "$file")
		file_lines=$(tlog_get_line_count "$file")
		printf 'file:   %s (%s bytes, %s lines)\n' "$file" "$file_bytes" "$file_lines"

		if [[ -n "$_tlog_cursor_value" ]]; then
			local delta
			if [[ "$_tlog_cursor_mode" == "lines" ]]; then
				delta=$((file_lines - _tlog_cursor_value))
			else
				delta=$((file_bytes - _tlog_cursor_value))
			fi
			if [[ $delta -gt 0 ]]; then
				printf 'delta:  %s %s pending\n' "$delta" "$_tlog_cursor_mode"
			elif [[ $delta -eq 0 ]]; then
				printf 'delta:  up to date\n'
			else
				printf 'delta:  cursor ahead by %s (rotation expected)\n' "$((-delta))"
			fi
		fi
	elif [[ -n "$file" ]]; then
		printf 'file:   %s (not found)\n' "$file"
	fi

	# Related files
	if [[ -f "$BASERUN/${name}.jts" ]]; then
		local jts_val
		read -r jts_val < "$BASERUN/${name}.jts" 2>/dev/null || true  # read exits 1 on EOF; not an error
		printf 'jts:    %s\n' "$jts_val"
	fi
	if [[ -f "$BASERUN/${name}.lock" ]]; then
		printf 'lock:   %s (present)\n' "$BASERUN/${name}.lock"
	fi

	return 0
}

_tlog_cmd_reset() {
	local name="${_ARGS[0]:-}"

	if [[ -z "$name" ]]; then
		echo "tlog: --reset requires a cursor name" >&2
		exit 1
	fi

	_tlog_validate_name "$name" || exit 1
	_tlog_require_baserun

	local found=0

	# Cursor file
	if [[ -f "$BASERUN/$name" ]]; then
		command rm -f "$BASERUN/$name"
		printf 'removed: %s\n' "$BASERUN/$name"
		found=1
	fi

	# Journal timestamp
	if [[ -f "$BASERUN/${name}.jts" ]]; then
		command rm -f "$BASERUN/${name}.jts"
		printf 'removed: %s\n' "$BASERUN/${name}.jts"
		found=1
	fi

	# Lock file
	if [[ -f "$BASERUN/${name}.lock" ]]; then
		command rm -f "$BASERUN/${name}.lock"
		printf 'removed: %s\n' "$BASERUN/${name}.lock"
		found=1
	fi

	# Orphaned temp files (mktemp pattern: .NAME.XXXXXX)
	local tmpfile
	for tmpfile in "$BASERUN"/."${name}".??????; do
		if [[ -f "$tmpfile" ]]; then
			command rm -f "$tmpfile"
			printf 'removed: %s\n' "$tmpfile"
			found=1
		fi
	done

	# Orphaned JTS temp files (mktemp pattern: .NAME.jts.XXXXXX)
	for tmpfile in "$BASERUN"/."${name}".jts.??????; do
		if [[ -f "$tmpfile" ]]; then
			command rm -f "$tmpfile"
			printf 'removed: %s\n' "$tmpfile"
			found=1
		fi
	done

	if [[ $found -eq 0 ]]; then
		printf 'no cursor files found for: %s\n' "$name"
	fi

	return 0
}

case "$_CMD" in
	read)
		# Backward-compatible positional: tlog <file> <tlog_name> [mode]
		if [[ ${#_ARGS[@]} -lt 2 ]]; then
			echo "tlog: read requires <file> and <tlog_name>" >&2
			exit 1
		fi

		_tlog_validate_name "${_ARGS[1]}" || exit 1
		_tlog_require_baserun

		# 3rd positional arg overrides -m flag (matches library priority)
		if [[ -n "${_ARGS[2]:-}" ]]; then
			if [[ "${_ARGS[2]}" != "bytes" ]] && [[ "${_ARGS[2]}" != "lines" ]]; then
				echo "tlog: invalid mode '${_ARGS[2]}' (must be 'bytes' or 'lines')" >&2
				exit 1
			fi
			tlog_read "${_ARGS[0]}" "${_ARGS[1]}" "$BASERUN" "${_ARGS[2]}"
		else
			tlog_read "${_ARGS[0]}" "${_ARGS[1]}" "$BASERUN" "${TLOG_MODE:-bytes}"
		fi
		exit $?
		;;

	full)
		if [[ ${#_ARGS[@]} -lt 1 ]]; then
			echo "tlog: --full requires a file path" >&2
			exit 1
		fi

		tlog_read_full "${_ARGS[0]}" "${_ARGS[1]:-0}"
		exit $?
		;;

	status)
		_tlog_cmd_status
		exit $?
		;;

	reset)
		_tlog_cmd_reset
		exit $?
		;;

	adjust)
		if [[ ${#_ARGS[@]} -lt 2 ]]; then
			echo "tlog: --adjust requires <name> and <delta>" >&2
			exit 1
		fi

		_tlog_validate_name "${_ARGS[0]}" || exit 1
		_tlog_require_baserun

		tlog_adjust_cursor "${_ARGS[0]}" "$BASERUN" "${_ARGS[1]}"
		exit $?
		;;

	*)
		echo "tlog: unknown command: $_CMD" >&2
		exit 1
		;;
esac
