#!/usr/bin/env bash

############################################################################################
#### logrotate-smoke-tests.sh — Verify logrotate configs, rotation, and log hygiene     ####
#### Zero external dependencies. Read-only — never modifies configs or log files.        ####
#### Requires: bash 4+, coreutils                                                       ####
####                                                                                    ####
#### Author: Phil Connor                                                                ####
#### Contact: contact@mylinux.work                                                      ####
#### License: MIT                                                                       ####
#### Version 1.01                                                                       ####
####                                                                                    ####
#### Usage:                                                                             ####
####   sudo ./logrotate-smoke-tests.sh                                                  ####
####                                                                                    ####
#### See --help for all options.                                                        ####
############################################################################################

set -euo pipefail

# ── Defaults ──────────────────────────────────────────────────────────
LOG_DIR="${LOG_DIR:-/var/log}"
MAX_LOG_SIZE_MB="${MAX_LOG_SIZE_MB:-500}"
MAX_DISK_USAGE_PCT="${MAX_DISK_USAGE_PCT:-80}"
MAX_ARCHIVE_AGE_DAYS="${MAX_ARCHIVE_AGE_DAYS:-90}"
SKIP_SYNTAX="${SKIP_SYNTAX:-false}"
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"  # text, tap, junit
JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"

# ── State ─────────────────────────────────────────────────────────────
PASS=0
FAIL=0
SKIP=0
TOTAL=0
RESULTS=()
START_TIME=""

# ── Colors ────────────────────────────────────────────────────────────
setup_colors() {
    if [[ "$COLOR" == "never" ]]; then
        RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
        return
    fi
    if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
        RED='\033[0;31m'
        GREEN='\033[0;32m'
        YELLOW='\033[0;33m'
        BLUE='\033[0;34m'
        BOLD='\033[1m'
        RESET='\033[0m'
    else
        RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
    fi
}

# ── Logging ───────────────────────────────────────────────────────────
log()     { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn()    { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err()     { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; }

# ── Test Result Recording ─────────────────────────────────────────────
record_pass() {
    local name="$1"
    local detail="${2:-}"
    ((PASS++)) || true
    ((TOTAL++)) || true
    RESULTS+=("PASS|${name}|${detail}")
    if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
        echo "ok ${TOTAL} - ${name}"
    else
        echo -e "  ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"
    fi
}

record_fail() {
    local name="$1"
    local detail="${2:-}"
    ((FAIL++)) || true
    ((TOTAL++)) || true
    RESULTS+=("FAIL|${name}|${detail}")
    if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
        echo "not ok ${TOTAL} - ${name}"
        [[ -n "$detail" ]] && echo "  # ${detail}"
    else
        echo -e "  ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"
    fi
}

record_skip() {
    local name="$1"
    local reason="${2:-}"
    ((SKIP++)) || true
    ((TOTAL++)) || true
    RESULTS+=("SKIP|${name}|${reason}")
    if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
        echo "ok ${TOTAL} - ${name} # SKIP ${reason}"
    else
        echo -e "  ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"
    fi
}

# ══════════════════════════════════════════════════════════════════════
#                          TEST SUITES
# ══════════════════════════════════════════════════════════════════════

# ── 1. Binary & Config ───────────────────────────────────────────────
test_binary_and_config() {
    if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
        echo ""
        echo -e "${BOLD}Binary & Config${RESET}"
    fi

    # 1. logrotate binary exists and is executable
    local lr_path
    lr_path=$(command -v logrotate 2>/dev/null) || lr_path=""
    if [[ -n "$lr_path" && -x "$lr_path" ]]; then
        record_pass "logrotate binary exists" "$lr_path"
    else
        record_fail "logrotate binary exists" "logrotate not found in PATH"
    fi

    # 2. /etc/logrotate.conf exists and is readable
    if [[ -r /etc/logrotate.conf ]]; then
        record_pass "/etc/logrotate.conf exists and is readable"
    else
        record_fail "/etc/logrotate.conf exists and is readable" "file missing or not readable"
    fi

    # 4. /etc/logrotate.d/ directory exists with configs
    if [[ -d /etc/logrotate.d ]]; then
        local conf_count
        conf_count=$(find /etc/logrotate.d -maxdepth 1 -type f 2>/dev/null | wc -l)
        if [[ "$conf_count" -gt 0 ]]; then
            record_pass "/etc/logrotate.d/ directory exists" "${conf_count} config files"
        else
            record_fail "/etc/logrotate.d/ directory exists" "directory exists but contains no config files"
        fi
    else
        record_fail "/etc/logrotate.d/ directory exists" "directory missing"
    fi
}

# ── 2. Config Syntax ─────────────────────────────────────────────────
test_config_syntax() {
    if [[ "$SKIP_SYNTAX" == "true" ]]; then
        if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
            echo ""
            echo -e "${BOLD}Config Syntax${RESET}"
        fi
        record_skip "logrotate.conf syntax valid" "SKIP_SYNTAX=true"
        return
    fi

    local lr_path
    lr_path=$(command -v logrotate 2>/dev/null) || lr_path=""
    if [[ -z "$lr_path" ]]; then
        if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
            echo ""
            echo -e "${BOLD}Config Syntax${RESET}"
        fi
        record_skip "logrotate.conf syntax valid" "logrotate binary not found"
        return
    fi

    if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
        echo ""
        echo -e "${BOLD}Config Syntax${RESET}"
    fi

    # 3. logrotate config syntax valid
    local syntax_output
    if syntax_output=$(logrotate -d /etc/logrotate.conf 2>&1); then
        record_pass "logrotate.conf syntax valid"
        verbose "logrotate -d output: ${syntax_output:0:200}"
    else
        record_fail "logrotate.conf syntax valid" "logrotate -d returned errors"
        verbose "logrotate -d output: ${syntax_output:0:500}"
    fi

    # 5. Individual config syntax check
    if [[ -d /etc/logrotate.d ]]; then
        local cfg
        for cfg in /etc/logrotate.d/*; do
            [[ -f "$cfg" ]] || continue
            local cfg_name
            cfg_name=$(basename "$cfg")

            local cfg_output
            if cfg_output=$(logrotate -d "$cfg" 2>&1); then
                record_pass "logrotate.d/${cfg_name}" "syntax valid"
            else
                record_fail "logrotate.d/${cfg_name}" "syntax error"
                verbose "logrotate -d ${cfg} output: ${cfg_output:0:500}"
            fi
        done
    fi
}

# ── 3. Log Hygiene ───────────────────────────────────────────────────
test_log_hygiene() {
    if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
        echo ""
        echo -e "${BOLD}Log Hygiene${RESET}"
    fi

    # 6. Compressed log archives exist where expected
    local archive_count
    archive_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) 2>/dev/null | wc -l)
    if [[ "$archive_count" -gt 0 ]]; then
        record_pass "Compressed log archives exist" "${archive_count} archives found"
    else
        record_fail "Compressed log archives exist" "no .gz or .xz files found in ${LOG_DIR}"
    fi

    # 7. No oversized unrotated logs
    local oversized
    oversized=$(find "$LOG_DIR" -maxdepth 2 -type f -not -name '*.gz' -not -name '*.xz' \
        -size +"${MAX_LOG_SIZE_MB}M" 2>/dev/null) || oversized=""
    if [[ -z "$oversized" ]]; then
        record_pass "No oversized unrotated logs" "all under ${MAX_LOG_SIZE_MB} MB"
    else
        local oversized_count
        oversized_count=$(echo "$oversized" | wc -l)
        local first_file
        first_file=$(echo "$oversized" | head -1)
        record_fail "No oversized unrotated logs" "${oversized_count} file(s) exceed ${MAX_LOG_SIZE_MB} MB (e.g. ${first_file})"
    fi

    # 8. Log directory permissions
    if [[ -d "$LOG_DIR" ]]; then
        local dir_owner
        dir_owner=$(stat -c '%U' "$LOG_DIR" 2>/dev/null) || dir_owner="unknown"
        local dir_perms
        dir_perms=$(stat -c '%a' "$LOG_DIR" 2>/dev/null) || dir_perms="000"

        local world_writable=false
        if [[ "${dir_perms: -1}" =~ [2367] ]]; then
            world_writable=true
        fi

        if [[ "$dir_owner" == "root" && "$world_writable" == "false" ]]; then
            record_pass "${LOG_DIR} permissions" "owner ${dir_owner}, mode ${dir_perms}"
        elif [[ "$dir_owner" != "root" ]]; then
            record_fail "${LOG_DIR} permissions" "owner is ${dir_owner}, expected root"
        else
            record_fail "${LOG_DIR} permissions" "world-writable (mode ${dir_perms})"
        fi
    else
        record_fail "${LOG_DIR} permissions" "directory does not exist"
    fi
}

# ── 4. Disk & Retention ──────────────────────────────────────────────
test_disk_and_retention() {
    if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
        echo ""
        echo -e "${BOLD}Disk & Retention${RESET}"
    fi

    # 9. /var/log disk usage under threshold
    local usage_pct
    usage_pct=$(df "$LOG_DIR" 2>/dev/null | awk 'NR==2 {gsub(/%/,""); print $5}') || usage_pct=""
    if [[ -n "$usage_pct" ]]; then
        if [[ "$usage_pct" -lt "$MAX_DISK_USAGE_PCT" ]]; then
            record_pass "${LOG_DIR} disk usage" "${usage_pct}% (under ${MAX_DISK_USAGE_PCT}% threshold)"
        else
            record_fail "${LOG_DIR} disk usage" "${usage_pct}% (exceeds ${MAX_DISK_USAGE_PCT}% threshold)"
        fi
    else
        record_skip "${LOG_DIR} disk usage" "could not determine disk usage"
    fi

    # 10. Stale .gz/.xz files older than retention threshold
    local stale_count
    stale_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) \
        -mtime +"${MAX_ARCHIVE_AGE_DAYS}" 2>/dev/null | wc -l)
    if [[ "$stale_count" -eq 0 ]]; then
        record_pass "No stale archives" "none older than ${MAX_ARCHIVE_AGE_DAYS} days"
    else
        record_fail "No stale archives" "${stale_count} archive(s) older than ${MAX_ARCHIVE_AGE_DAYS} days"
    fi

    # 11. logrotate status file exists and is recent
    local status_file="/var/lib/logrotate/logrotate.status"
    if [[ ! -f "$status_file" ]]; then
        # Some distros use /var/lib/logrotate.status
        status_file="/var/lib/logrotate.status"
    fi

    if [[ -f "$status_file" ]]; then
        local status_mtime
        status_mtime=$(stat -c '%Y' "$status_file" 2>/dev/null) || status_mtime=0
        local now
        now=$(date +%s)
        local age_days=$(( (now - status_mtime) / 86400 ))

        if [[ "$age_days" -le 2 ]]; then
            local status_date
            status_date=$(date -d "@${status_mtime}" +%Y-%m-%d 2>/dev/null) || status_date="unknown"
            record_pass "logrotate status file" "updated ${status_date}"
        else
            record_fail "logrotate status file" "last updated ${age_days} days ago"
        fi
    else
        record_fail "logrotate status file" "not found at /var/lib/logrotate/logrotate.status or /var/lib/logrotate.status"
    fi
}

# ── 5. Common Misconfigs ─────────────────────────────────────────────
test_misconfigs() {
    if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
        echo ""
        echo -e "${BOLD}Common Misconfigs${RESET}"
    fi

    if [[ ! -d /etc/logrotate.d ]]; then
        record_skip "Config 'rotate' directive check" "/etc/logrotate.d not found"
        record_skip "Config 'compress' directive check" "/etc/logrotate.d not found"
        return
    fi

    # 12a. Check for missing 'rotate' directive
    local missing_rotate=()
    local cfg
    for cfg in /etc/logrotate.d/*; do
        [[ -f "$cfg" ]] || continue
        if ! grep -qE '^\s*rotate\s+' "$cfg" 2>/dev/null; then
            # Check if the main config has a global rotate
            if ! grep -qE '^\s*rotate\s+' /etc/logrotate.conf 2>/dev/null; then
                missing_rotate+=("$(basename "$cfg")")
            fi
        fi
    done

    if [[ ${#missing_rotate[@]} -eq 0 ]]; then
        record_pass "All configs have 'rotate' directive"
    else
        record_fail "All configs have 'rotate' directive" "missing in: ${missing_rotate[*]}"
    fi

    # 12b. Check for missing 'compress' directive
    local missing_compress=()
    for cfg in /etc/logrotate.d/*; do
        [[ -f "$cfg" ]] || continue
        if ! grep -qE '^\s*(compress|nocompress)' "$cfg" 2>/dev/null; then
            if ! grep -qE '^\s*compress' /etc/logrotate.conf 2>/dev/null; then
                missing_compress+=("$(basename "$cfg")")
            fi
        fi
    done

    if [[ ${#missing_compress[@]} -eq 0 ]]; then
        record_pass "All configs have 'compress' directive"
    else
        record_fail "All configs have 'compress' directive" "missing in: ${missing_compress[*]}"
    fi
}

# ══════════════════════════════════════════════════════════════════════
#                          OUTPUT
# ══════════════════════════════════════════════════════════════════════

print_summary() {
    local end_time
    end_time=$(date +%s)
    local duration=$(( end_time - START_TIME ))

    echo ""
    echo -e "${BOLD}────────────────────────────────────────${RESET}"
    echo -e "${BOLD}Summary${RESET}  ${LOG_DIR}"
    echo -e "  ${GREEN}${PASS} passed${RESET}  ${RED}${FAIL} failed${RESET}  ${YELLOW}${SKIP} skipped${RESET}  (${duration}s)"
    echo -e "${BOLD}────────────────────────────────────────${RESET}"

    if [[ $FAIL -eq 0 ]]; then
        echo -e "${GREEN}${BOLD}All tests passed.${RESET}"
    else
        echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"
    fi
}

print_tap_header() {
    echo "TAP version 13"
}

print_tap_footer() {
    echo "1..${TOTAL}"
    echo "# pass ${PASS}"
    echo "# fail ${FAIL}"
    echo "# skip ${SKIP}"
}

write_junit() {
    local end_time
    end_time=$(date +%s)
    local duration=$(( end_time - START_TIME ))

    cat > "$JUNIT_FILE" <<JUNIT_EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
  <testsuite name="logrotate-smoke-tests" tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
JUNIT_EOF

    for result in "${RESULTS[@]}"; do
        local status name detail
        status=$(echo "$result" | cut -d'|' -f1)
        name=$(echo "$result" | cut -d'|' -f2)
        detail=$(echo "$result" | cut -d'|' -f3)

        # XML-escape the values
        name=$(echo "$name" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g')
        detail=$(echo "$detail" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g')

        case "$status" in
            PASS)
                echo "    <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
                [[ -n "$detail" ]] && echo "      <system-out>${detail}</system-out>" >> "$JUNIT_FILE"
                echo "    </testcase>" >> "$JUNIT_FILE"
                ;;
            FAIL)
                echo "    <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
                echo "      <failure message=\"${detail}\">FAILED: ${name} — ${detail}</failure>" >> "$JUNIT_FILE"
                echo "    </testcase>" >> "$JUNIT_FILE"
                ;;
            SKIP)
                echo "    <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
                echo "      <skipped message=\"${detail}\"/>" >> "$JUNIT_FILE"
                echo "    </testcase>" >> "$JUNIT_FILE"
                ;;
        esac
    done

    echo "  </testsuite>" >> "$JUNIT_FILE"
    echo "</testsuites>" >> "$JUNIT_FILE"

    log "JUnit report written to ${JUNIT_FILE}"
}

# ══════════════════════════════════════════════════════════════════════
#                          MAIN
# ══════════════════════════════════════════════════════════════════════

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Smoke-test logrotate configuration, rotation health, and log hygiene.
Read-only — never modifies configs or log files.

Options:
  --format FORMAT     Output: text (default), tap, junit
  --junit-file FILE   JUnit output path (default: smoke-results.xml)
  --verbose           Show debug output
  --no-color          Disable colored output
  --help              Show this help

Environment variables:
  LOG_DIR               Log directory (default: /var/log)
  MAX_LOG_SIZE_MB       Oversized log threshold in MB (default: 500)
  MAX_DISK_USAGE_PCT    Disk usage threshold in % (default: 80)
  MAX_ARCHIVE_AGE_DAYS  Stale archive threshold in days (default: 90)
  SKIP_SYNTAX           Skip logrotate -d syntax checks (default: false)

Examples:
  # Basic run
  sudo ./$(basename "$0")

  # Custom thresholds
  MAX_LOG_SIZE_MB=200 MAX_DISK_USAGE_PCT=70 sudo ./$(basename "$0")

  # Skip syntax checks
  SKIP_SYNTAX=true sudo ./$(basename "$0")

  # JUnit output for CI
  sudo ./$(basename "$0") --format junit --junit-file results.xml

  # TAP output
  sudo ./$(basename "$0") --format tap
EOF
}

main() {
    # Parse arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --format)       OUTPUT_FORMAT="$2"; shift ;;
            --junit-file)   JUNIT_FILE="$2"; shift ;;
            --verbose)      VERBOSE=true ;;
            --no-color)     COLOR=never ;;
            --help|-h)      usage; exit 0 ;;
            *)              err "Unknown option: $1"; usage; exit 1 ;;
        esac
        shift
    done

    setup_colors

    START_TIME=$(date +%s)

    if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
        print_tap_header
    else
        echo ""
        echo -e "${BOLD}Log Rotation Smoke Tests${RESET}"
        echo -e "Target: ${LOG_DIR}"
        echo -e "Time:   $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    fi

    # Run test suites
    test_binary_and_config
    test_config_syntax
    test_log_hygiene
    test_disk_and_retention
    test_misconfigs

    # Output
    if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
        print_tap_footer
    elif [[ "$OUTPUT_FORMAT" == "junit" ]]; then
        print_summary
        write_junit
    else
        print_summary
    fi

    # Exit code
    [[ $FAIL -eq 0 ]] && exit 0 || exit 1
}

main "$@"
