#!/usr/bin/env bash

##############################################################################
#### Salt State Linter                                                    ####
####                                                                      ####
#### Validates Salt .sls state files for common mistakes — duplicate      ####
#### state IDs, YAML syntax issues, missing requisites, Jinja errors,     ####
#### and anti-patterns. No dependencies beyond bash.                      ####
####                                                                      ####
#### Usage:                                                               ####
####   ./salt-state-linter.sh /srv/salt/                                  ####
####   ./salt-state-linter.sh /srv/salt/nginx/init.sls                    ####
####   ./salt-state-linter.sh --json /srv/salt/                           ####
####                                                                      ####
#### Author:  Phil Connor                                                 ####
#### Contact: contact@mylinux.work                                        ####
#### License: MIT                                                         ####
#### Version: 1.0                                                         ####
##############################################################################

set -o pipefail

readonly VERSION="1.0"
readonly SCRIPT_NAME="$(basename "$0")"

# --- defaults ---
OUTPUT_JSON=false
QUIET=false
USE_COLOR=true
EXCLUDE_PATTERNS=()

# --- counters ---
FILES_SCANNED=0
TOTAL_ERRORS=0
TOTAL_WARNINGS=0

# --- result collection ---
declare -a RESULTS_TEXT=()
declare -a RESULTS_JSON=()

# --- colors ---
setup_colors() {
    if [[ "$USE_COLOR" == true ]] && [[ -t 1 ]]; then
        RED='\033[0;31m'
        YLW='\033[0;33m'
        GRN='\033[0;32m'
        BLD='\033[1m'
        RST='\033[0m'
    else
        RED='' YLW='' GRN='' BLD='' RST=''
    fi
}

# --- usage ---
usage() {
    cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] PATH [PATH...]

Validate Salt .sls state files for common mistakes.

Options:
  --json            Output results as JSON
  --quiet           Only show errors (no warnings)
  --no-color        Disable colored output
  --exclude PATTERN Exclude files matching pattern (can repeat)
  -h, --help        Show this help
  --version         Show version

Exit codes:
  0  No errors found (warnings are OK)
  1  Errors found
  2  Usage error
EOF
}

# --- record finding ---
record() {
    local file="$1" line="$2" level="$3" rule="$4" message="$5"

    if [[ "$level" == "error" ]]; then
        ((TOTAL_ERRORS++))
        local tag="ERROR"
        local color="$RED"
    else
        ((TOTAL_WARNINGS++))
        if [[ "$QUIET" == true ]]; then
            return
        fi
        local tag="WARN"
        local color="$YLW"
    fi

    RESULTS_TEXT+=("${color}${file}:${line}: ${tag}: ${message}${RST}")

    # escape strings for JSON
    local jfile jmsg
    jfile="$(printf '%s' "$file" | sed 's/\\/\\\\/g; s/"/\\"/g')"
    jmsg="$(printf '%s' "$message" | sed 's/\\/\\\\/g; s/"/\\"/g')"
    RESULTS_JSON+=("{\"file\":\"${jfile}\",\"line\":${line},\"level\":\"${level}\",\"rule\":\"${rule}\",\"message\":\"${jmsg}\"}")
}

# --- check: duplicate state IDs ---
check_duplicate_ids() {
    local file="$1"
    declare -A seen_ids=()
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        # state IDs: start at column 0, alphanumeric/underscore/hyphen, end with colon
        # skip comments, Jinja blocks, blank lines, lines starting with whitespace
        [[ "$line" =~ ^[[:space:]] ]] && continue
        [[ "$line" =~ ^# ]] && continue
        [[ "$line" =~ ^\{[%\{] ]] && continue
        [[ -z "$line" ]] && continue
        [[ "$line" == "---" ]] && continue
        [[ "$line" =~ ^include: ]] && continue

        if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_.-]*):(.*)$ ]]; then
            local id="${BASH_REMATCH[1]}"
            # skip salt module function lines (e.g., pkg.installed:)
            # state IDs don't contain dots typically; module.function does
            [[ "$id" == *.* ]] && continue

            if [[ -n "${seen_ids[$id]+x}" ]]; then
                record "$file" "$lineno" "error" "duplicate-id" \
                    "Duplicate state ID '${id}' (first seen line ${seen_ids[$id]})"
            else
                seen_ids["$id"]="$lineno"
            fi
        fi
    done < "$file"
}

# --- check: tab indentation ---
check_tabs() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        if [[ "$line" == *$'\t'* ]]; then
            record "$file" "$lineno" "error" "tab-indent" \
                "Tab character found — use spaces for YAML indentation"
        fi
    done < "$file"
}

# --- check: trailing whitespace ---
check_trailing_whitespace() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        if [[ "$line" =~ [[:space:]]$ ]] && [[ -n "$line" ]]; then
            record "$file" "$lineno" "warning" "trailing-whitespace" \
                "Trailing whitespace"
        fi
    done < "$file"
}

# --- check: cmd.run without guards ---
check_cmd_no_guard() {
    local file="$1"
    local -a lines=()
    local total=0

    while IFS= read -r line; do
        lines+=("$line")
        ((total++))
    done < "$file"

    local i=0
    while (( i < total )); do
        local line="${lines[$i]}"
        local lineno=$((i + 1))

        # match cmd.run, cmd.script, cmd.wait in state function position (indented)
        if [[ "$line" =~ ^[[:space:]]+(cmd\.(run|script)):$ ]] || \
           [[ "$line" =~ ^[[:space:]]+-(\ )+cmd\.(run|script)$ ]]; then
            local has_guard=false
            local j=$((i + 1))
            # look ahead up to 15 lines for unless/onlyif/creates
            while (( j < total && j <= i + 15 )); do
                local next="${lines[$j]}"
                # stop if we hit another state ID or module function at same/lower indent
                [[ "$next" =~ ^[a-zA-Z] ]] && break
                if [[ "$next" =~ ^[[:space:]]+-(\ )+(unless|onlyif|creates): ]] || \
                   [[ "$next" =~ ^[[:space:]]+(unless|onlyif|creates): ]]; then
                    has_guard=true
                    break
                fi
                ((j++))
            done
            if [[ "$has_guard" == false ]]; then
                record "$file" "$lineno" "warning" "cmd-no-guard" \
                    "cmd.run without unless/onlyif/creates (non-idempotent)"
            fi
        fi
        ((i++))
    done
}

# --- check: bare wildcard targeting ---
check_bare_wildcard() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        if [[ "$line" =~ salt[[:space:]]+[\"\']\*[\"\'] ]] || \
           [[ "$line" =~ salt[[:space:]]+\'\*\' ]] || \
           [[ "$line" =~ \"tgt\":[[:space:]]*\"\*\" ]]; then
            record "$file" "$lineno" "warning" "bare-wildcard" \
                "Bare '*' targeting in SLS file — verify this is intentional"
        fi
    done < "$file"
}

# --- check: hardcoded secrets ---
check_hardcoded_secrets() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        # skip comments and Jinja-only lines
        [[ "$line" =~ ^[[:space:]]*# ]] && continue

        # check for password/secret patterns with inline values (not pillar refs)
        if [[ "$line" =~ (password|passwd|secret|api_key|private_key|token):[[:space:]]+[^\{] ]]; then
            local matched="${BASH_REMATCH[1]}"
            # skip if the value is a pillar/grains reference
            if [[ ! "$line" =~ (pillar|salt\[|grains) ]]; then
                record "$file" "$lineno" "warning" "hardcoded-secret" \
                    "Possible hardcoded secret on line with '${matched}:' — use pillar data instead"
            fi
        fi
    done < "$file"
}

# --- check: unclosed Jinja blocks ---
check_jinja_syntax() {
    local file="$1"
    local content
    content="$(<"$file")"

    # count opening and closing Jinja block tags
    local open_block close_block open_var close_var
    open_block=$(grep -o '{%' <<< "$content" 2>/dev/null | wc -l)
    close_block=$(grep -o '%}' <<< "$content" 2>/dev/null | wc -l)
    open_var=$(grep -o '{{' <<< "$content" 2>/dev/null | wc -l)
    close_var=$(grep -o '}}' <<< "$content" 2>/dev/null | wc -l)

    if (( open_block != close_block )); then
        record "$file" 1 "error" "jinja-block" \
            "Mismatched Jinja block tags: ${open_block} '{%' vs ${close_block} '%}'"
    fi

    if (( open_var != close_var )); then
        record "$file" 1 "error" "jinja-variable" \
            "Mismatched Jinja variable tags: ${open_var} '{{' vs ${close_var} '}}'"
    fi
}

# --- check: YAML boolean gotchas ---
check_yaml_booleans() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        # skip comments
        [[ "$line" =~ ^[[:space:]]*# ]] && continue
        # skip Jinja lines
        [[ "$line" =~ ^\{[%\{] ]] && continue

        # match key: value where value is bare yes/no/true/false (case-insensitive)
        # must be a YAML mapping value, not inside a Jinja expression
        if [[ "$line" =~ ^[[:space:]]+(-[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_]*:[[:space:]]+(yes|no|true|false|Yes|No|True|False|YES|NO|TRUE|FALSE)[[:space:]]*$ ]]; then
            local val="${BASH_REMATCH[2]}"
            # skip if the key itself is a known Salt boolean field
            if [[ "$line" =~ (enabled|enable|reload|watch_in|recurse|makedirs|replace|follow_symlinks|backup): ]]; then
                continue
            fi
            record "$file" "$lineno" "warning" "yaml-boolean" \
                "Bare YAML boolean '${val}' — quote it if a string value is intended"
        fi
    done < "$file"
}

# --- check: long lines ---
check_long_lines() {
    local file="$1"
    local lineno=0

    while IFS= read -r line; do
        ((lineno++))
        if (( ${#line} > 200 )); then
            record "$file" "$lineno" "warning" "long-line" \
                "Line exceeds 200 characters (${#line} chars)"
        fi
    done < "$file"
}

# --- check: file.managed without source or contents ---
check_file_managed_no_source() {
    local file="$1"
    local -a lines=()
    local total=0

    while IFS= read -r line; do
        lines+=("$line")
        ((total++))
    done < "$file"

    local i=0
    while (( i < total )); do
        local line="${lines[$i]}"
        local lineno=$((i + 1))

        if [[ "$line" =~ ^[[:space:]]+(file\.managed):$ ]] || \
           [[ "$line" =~ ^[[:space:]]+-(\ )+file\.managed$ ]]; then
            local has_source=false
            local j=$((i + 1))
            while (( j < total && j <= i + 20 )); do
                local next="${lines[$j]}"
                # stop at next state ID or module function
                [[ "$next" =~ ^[a-zA-Z] ]] && break
                if [[ "$next" =~ ^[[:space:]]+-(\ )+(source|contents|contents_pillar|contents_grains|contents_newline): ]] || \
                   [[ "$next" =~ ^[[:space:]]+(source|contents|contents_pillar|contents_grains|contents_newline): ]]; then
                    has_source=true
                    break
                fi
                ((j++))
            done
            if [[ "$has_source" == false ]]; then
                record "$file" "$lineno" "warning" "file-managed-no-source" \
                    "file.managed without source or contents parameter"
            fi
        fi
        ((i++))
    done
}

# --- check: missing requisite targets ---
check_missing_requisites() {
    local file="$1"

    # first pass: collect all state IDs in the file
    declare -A state_ids=()
    local lineno=0
    while IFS= read -r line; do
        ((lineno++))
        [[ "$line" =~ ^[[:space:]] ]] && continue
        [[ "$line" =~ ^# ]] && continue
        [[ "$line" =~ ^\{[%\{] ]] && continue
        [[ -z "$line" ]] && continue
        [[ "$line" == "---" ]] && continue
        [[ "$line" =~ ^include: ]] && continue

        if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_.-]*):(.*)$ ]]; then
            local id="${BASH_REMATCH[1]}"
            [[ "$id" == *.* ]] && continue
            state_ids["$id"]=1
        fi
    done < "$file"

    # second pass: find requisite references
    lineno=0
    local in_requisite=false
    while IFS= read -r line; do
        ((lineno++))

        # match requisite target lines like:  - pkg: nginx_install
        if [[ "$line" =~ ^[[:space:]]+-[[:space:]]+(pkg|file|service|cmd|user|group|git|pip|cron|mount|kmod):[[:space:]]+([a-zA-Z_][a-zA-Z0-9_.-]*)[[:space:]]*$ ]]; then
            local req_target="${BASH_REMATCH[2]}"
            # skip if target contains Jinja
            [[ "$req_target" =~ \{ ]] && continue
            if [[ -z "${state_ids[$req_target]+x}" ]]; then
                record "$file" "$lineno" "warning" "missing-requisite" \
                    "Requisite target '${req_target}' not found as state ID in this file"
            fi
        fi
    done < "$file"
}

# --- process a single file ---
lint_file() {
    local file="$1"

    # check exclude patterns
    for pattern in "${EXCLUDE_PATTERNS[@]}"; do
        if [[ "$file" == *${pattern}* ]]; then
            return
        fi
    done

    ((FILES_SCANNED++))

    check_duplicate_ids "$file"
    check_tabs "$file"
    check_trailing_whitespace "$file"
    check_cmd_no_guard "$file"
    check_bare_wildcard "$file"
    check_hardcoded_secrets "$file"
    check_jinja_syntax "$file"
    check_yaml_booleans "$file"
    check_long_lines "$file"
    check_file_managed_no_source "$file"
    check_missing_requisites "$file"
}

# --- collect .sls files ---
collect_files() {
    local path="$1"
    if [[ -f "$path" ]]; then
        if [[ "$path" == *.sls ]]; then
            echo "$path"
        else
            echo "Warning: ${path} is not an .sls file, skipping" >&2
        fi
    elif [[ -d "$path" ]]; then
        find "$path" -type f -name '*.sls' | sort
    else
        echo "Error: ${path} does not exist" >&2
        return 1
    fi
}

# --- JSON output ---
print_json() {
    echo "{"
    echo "  \"files_scanned\": ${FILES_SCANNED},"
    echo "  \"errors\": ${TOTAL_ERRORS},"
    echo "  \"warnings\": ${TOTAL_WARNINGS},"
    echo "  \"results\": ["

    local count=${#RESULTS_JSON[@]}
    local i=0
    for entry in "${RESULTS_JSON[@]}"; do
        ((i++))
        if (( i < count )); then
            echo "    ${entry},"
        else
            echo "    ${entry}"
        fi
    done

    echo "  ]"
    echo "}"
}

# --- text output ---
print_text() {
    for line in "${RESULTS_TEXT[@]}"; do
        echo -e "$line"
    done

    echo ""
    echo -e "${BLD}Scanned ${FILES_SCANNED} files${RST}"
    echo -e "Errors:   ${RED}${TOTAL_ERRORS}${RST}"
    echo -e "Warnings: ${YLW}${TOTAL_WARNINGS}${RST}"
}

# =============================================
# Main
# =============================================

main() {
    local paths=()

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --json)     OUTPUT_JSON=true; shift ;;
            --quiet)    QUIET=true; shift ;;
            --no-color) USE_COLOR=false; shift ;;
            --exclude)
                [[ -z "${2:-}" ]] && { echo "Error: --exclude requires a pattern" >&2; exit 2; }
                EXCLUDE_PATTERNS+=("$2"); shift 2 ;;
            -h|--help)    usage; exit 0 ;;
            --version)    echo "$SCRIPT_NAME $VERSION"; exit 0 ;;
            -*)           echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
            *)            paths+=("$1"); shift ;;
        esac
    done

    if [[ ${#paths[@]} -eq 0 ]]; then
        echo "Error: no path specified" >&2
        usage >&2
        exit 2
    fi

    setup_colors

    # collect and lint all files
    local all_files=()
    for p in "${paths[@]}"; do
        while IFS= read -r f; do
            all_files+=("$f")
        done < <(collect_files "$p")
    done

    if [[ ${#all_files[@]} -eq 0 ]]; then
        echo "No .sls files found" >&2
        exit 2
    fi

    for f in "${all_files[@]}"; do
        lint_file "$f"
    done

    # output results
    if [[ "$OUTPUT_JSON" == true ]]; then
        print_json
    else
        print_text
    fi

    # exit code
    if (( TOTAL_ERRORS > 0 )); then
        exit 1
    fi
    exit 0
}

main "$@"
