#!/usr/bin/env bash
#
# zrec - Interactive record inserter for recutils recfiles
# Compatible with bash 3.2.57 (macOS)
#
# Usage: zrec <recfile> [record-type]
#

TAB=$(printf '\t')

set -o pipefail

# ---------------------------------------------------------------------------
# Step 0: Verify recutils (recins/recsel) is installed
# ---------------------------------------------------------------------------
check_recutils() {
    if ! command -v recins >/dev/null 2>&1; then
        echo "Error: 'recins' not found. Please install recutils." >&2
        echo "  On macOS:  brew install recutils" >&2
        exit 1
    fi
    if ! command -v recinf >/dev/null 2>&1; then
        echo "Error: 'recinf' not found. Please install recutils." >&2
        exit 1
    fi

    local ver
    ver=$(recins --version 2>/dev/null | head -n1)
    echo "Using: ${ver:-recutils (version unknown)}"
}

# ---------------------------------------------------------------------------
# Step 1: Usage / argument handling
# ---------------------------------------------------------------------------
usage() {
    echo "Usage: $(basename "$0") <recfile> [record-type]" >&2
    echo "  Interactively build and insert a record into a recfile." >&2
    exit 1
}

# ---------------------------------------------------------------------------
# Step 2: Extract the descriptor block for a given record type.
# ---------------------------------------------------------------------------
get_descriptor_block() {
    local file="$1"
    local rectype="$2"

    awk -v rt="$rectype" '
        BEGIN { RS=""; FS="\n" }
        {
            for (i = 1; i <= NF; i++) {
                if ($i ~ /^%rec:[[:space:]]*/) {
                    name = $i
                    sub(/^%rec:[[:space:]]*/, "", name)
                    sub(/[[:space:]]+$/, "", name)
                    if (name == rt) {
                        print $0
                        exit
                    }
                }
            }
        }
    ' "$file"
}

# ---------------------------------------------------------------------------
# Step 3: List all record types defined in the file.
# ---------------------------------------------------------------------------
list_record_types() {
    local file="$1"

    if command -v recinf >/dev/null 2>&1; then
        recinf "$file" 2>/dev/null | awk '
            {
                if (NF == 0) next
                name = $NF
                if ($1 ~ /^[0-9]+$/) {
                    printf "%s [count: %s]\n", name, $1
                } else {
                    print name
                }
            }
        '
    else
        grep '^%rec:' "$file" | sed 's/^%rec:[[:space:]]*//; s/[[:space:]]*$//'
    fi
}

# ---------------------------------------------------------------------------
# Step 4: Pull a space-separated list of fields from a directive.
# ---------------------------------------------------------------------------
extract_fields() {
    local block="$1"
    local directive="$2"

    echo "$block" | awk -v d="$directive" '
        index($0, d) == 1 {
            line = substr($0, length(d) + 1)
            print line
        }
    ' | tr '\n' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}

# ---------------------------------------------------------------------------
# Step 5a: Resolve a named typedef back to its base type string.
#
# Handles two typedef forms:
#
#   %typedef: phonenum regexp /pattern/
#       -> returns "regexp /pattern/"
#
#   %typedef: score range 1 100
#       -> returns "range 1 100"
# ---------------------------------------------------------------------------
resolve_typedef() {
    local block="$1"
    local alias="$2"

    echo "$block" | awk -v a="$alias" '
        index($0, "%typedef:") == 1 {
            rest = substr($0, length("%typedef:") + 1)
            sub(/^[[:space:]]+/, "", rest)

            # parts[1] = alias name, parts[2..] = type definition tokens
            n = split(rest, parts, /[[:space:]]+/)
            if (parts[1] != a) next

            # Reassemble the definition (everything after the alias name).
            defn = ""
            for (i = 2; i <= n; i++) {
                defn = defn (i==2 ? "" : " ") parts[i]
            }

            print defn
            exit
        }
    '
}

# ---------------------------------------------------------------------------
# Step 5b: Find the declared type (if any) for a specific field.
#         Resolves %typedef: aliases to their underlying type string.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Look up a field's type ONCE and return two tab-separated values:
#
#       <resolved-type><TAB><display-label>
#
#   - resolved-type : the fully-resolved type string used for VALIDATION
#                     (e.g. "regexp /^.../", "int", "range 1 100").
#   - display-label : a human-friendly label used for the PROMPT
#                     (e.g. the alias "phonenum", or "regexp" for an
#                     inline pattern, or the base type name).
#
#   Both fields may be empty if the field has no declared type.
#   This performs a single awk pass over the descriptor block.
# ---------------------------------------------------------------------------
get_field_type_info() {
    local block="$1"
    local field="$2"

    # Single awk pass: pull the raw %type: token(s) for this field.
    local raw_type
    raw_type=$(echo "$block" | awk -v f="$field" '
        index($0, "%type:") == 1 {
            rest = substr($0, length("%type:") + 1)
            sub(/^[[:space:]]+/, "", rest)
            n = split(rest, parts, /[[:space:]]+/)
            names = parts[1]
            typedef = ""
            for (i = 2; i <= n; i++) {
                typedef = typedef (i==2 ? "" : " ") parts[i]
            }
            m = split(names, namearr, /,/)
            for (j = 1; j <= m; j++) {
                if (namearr[j] == f) {
                    print typedef
                    exit
                }
            }
        }
    ')

    # No declared type: emit an empty resolved/label pair.
    if [ -z "$raw_type" ]; then
        printf '\t'
        return 0
    fi

    local base="${raw_type%% *}"
    local resolved label

    case "$base" in
    int | real | bool | date | line | url | email | uuid | regexp | range)
        # Concrete inline type.
        resolved="$raw_type"
        # Hide the regexp pattern in the label; show just the keyword.
        label="$base"
        ;;
    *)
        # Named alias: resolve it (one typedef lookup, only when needed).
        local typedef
        typedef=$(resolve_typedef "$block" "$base")
        if [ -n "$typedef" ]; then
            resolved="$typedef" # validate against the underlying type
            label="$base"       # but display the friendly alias name
        else
            # Unknown alias: fall back to raw for both.
            resolved="$raw_type"
            label="$raw_type"
        fi
        ;;
    esac

    printf '%s\t%s' "$resolved" "$label"
}

# ---------------------------------------------------------------------------
# Helper: test membership of a word in a space-separated list.
# ---------------------------------------------------------------------------
in_list() {
    local needle="$1"
    shift
    local item
    for item in "$@"; do
        [ "$item" = "$needle" ] && return 0
    done
    return 1
}

# ---------------------------------------------------------------------------
# ### NEW: Step 6a: Validate (and where possible normalise) a value against
# a recutils field type string.  Prints the (possibly normalised) value on
# stdout and returns 0 on success, or prints an error to stderr and returns
# 1 on failure.
#
# Supported types:
#   int                        - integer
#   real                       - floating-point number
#   bool                       - yes/no/true/false/0/1  (normalised to "yes"/"no")
#   date                       - ISO-8601 or free date accepted by `date`
#   line                       - single line (no embedded newlines – always valid)
#   url                        - must start with a recognised scheme
#   email                      - must match user@domain
#   uuid                       - must match 8-4-4-4-12 hex pattern
#   regexp /pattern/           - value must match the given ERE
#   range [min] [max]          - min, max, or both may be omitted
# ---------------------------------------------------------------------------
validate_field_type() {
    local value="$1"
    local ftype="$2"

    local base_type="${ftype%% *}"

    case "$base_type" in

    int)
        if printf '%s' "$value" | grep -qE '^-?[0-9]+$'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected an integer (e.g. 42, -7)." >&2
        return 1
        ;;

    real)
        if printf '%s' "$value" | grep -qE '^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected a real number (e.g. 3.14, -2.0, 1e10)." >&2
        return 1
        ;;

    bool)
        case "$value" in
        1 | [Yy][Ee][Ss] | [Tt][Rr][Uu][Ee])
            printf 'yes'
            return 0
            ;;
        0 | [Nn][Oo] | [Ff][Aa][Ll][Ss][Ee])
            printf 'no'
            return 0
            ;;
        esac
        echo "  -> Expected a boolean: yes/no, true/false, or 1/0." >&2
        return 1
        ;;

    date)
        if date -d "$value" >/dev/null 2>&1 ||
            date -j -f "%Y-%m-%d" "$value" "+%Y-%m-%d" >/dev/null 2>&1; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected a valid date (e.g. 2024-07-04)." >&2
        return 1
        ;;

    line)
        printf '%s' "$value"
        return 0
        ;;

    url)
        if printf '%s' "$value" |
            grep -qiE '^(https?|ftp|ftps|sftp|file)://[^[:space:]]+$'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected a URL beginning with http://, https://, ftp://, etc." >&2
        return 1
        ;;

    email)
        if printf '%s' "$value" | grep -qE '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected an e-mail address (e.g. user@example.com)." >&2
        return 1
        ;;

    uuid)
        if printf '%s' "$value" |
            grep -qiE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000)." >&2
        return 1
        ;;

    regexp)
        local pattern="${ftype#regexp }"
        pattern="${pattern#/}"
        pattern="${pattern%/}"

        # ### CHANGED: Guard against empty pattern before attempting match.
        if [ -z "$pattern" ]; then
            echo "  -> Warning: empty regexp in type declaration; skipping validation." >&2
            printf '%s' "$value"
            return 0
        fi

        # ### CHANGED: Use awk for ERE matching instead of grep -E for
        # consistent behaviour across BSD (macOS) and GNU/Linux platforms.
        if printf '%s' "$value" |
            awk -v pat="$pattern" '$0 ~ pat { found=1 } END { exit !found }'; then
            printf '%s' "$value"
            return 0
        fi
        echo "  -> Value does not match required pattern: /${pattern}/." >&2
        return 1
        ;;

    range)
        if ! printf '%s' "$value" | grep -qE '^-?[0-9]+(\.[0-9]+)?$'; then
            echo "  -> Expected a number for a range field." >&2
            return 1
        fi

        local lo hi
        lo=$(echo "$ftype" | awk '{print $2}')
        hi=$(echo "$ftype" | awk '{print $3}')

        if [ -n "$lo" ] &&
            awk -v v="$value" -v lo="$lo" 'BEGIN { exit !(v < lo) }'; then
            echo "  -> Value must be >= ${lo}." >&2
            return 1
        fi
        if [ -n "$hi" ] &&
            awk -v v="$value" -v hi="$hi" 'BEGIN { exit !(v > hi) }'; then
            echo "  -> Value must be <= ${hi}." >&2
            return 1
        fi
        printf '%s' "$value"
        return 0
        ;;

    *)
        printf '%s' "$value"
        return 0
        ;;
    esac
}

# ---------------------------------------------------------------------------
# Step 6: Prompt for a single field's value.
#   Returns the (possibly normalised) value via stdout.
#   Mandatory fields loop until non-empty; typed fields loop until valid.
# ---------------------------------------------------------------------------
prompt_field() {
    local field="$1"
    local is_mandatory="$2" # "yes" / "no"
    local ftype="$3"        # resolved type, used for validation
    local type_label="$4"   # friendly label, used for display

    local label="$field"
    local tags=""
    [ "$is_mandatory" = "yes" ] && tags="${tags} [MANDATORY]"
    [ -n "$type_label" ] && tags="${tags} (type: ${type_label})"

    local value validated
    while true; do
        printf "%s%s: " "$label" "$tags" >&2
        IFS= read -r value

        # Mandatory-empty check.
        if [ "$is_mandatory" = "yes" ] && [ -z "$value" ]; then
            echo "  -> This field is mandatory and cannot be empty." >&2
            continue
        fi

        # Skip type validation for empty optional fields.
        if [ -z "$value" ]; then
            printf '%s' "$value"
            return 0
        fi

        # ### CHANGED: If a type is declared, validate (and possibly normalise)
        # the input; re-prompt on failure.
        if [ -n "$ftype" ]; then
            if validated=$(validate_field_type "$value" "$ftype"); then
                printf '%s' "$validated"
            else
                continue
            fi
        else
            printf '%s' "$value"
        fi
        return 0
    done
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
    check_recutils

    [ "$#" -ge 1 ] || usage
    local recfile="$1"
    local rectype="$2"

    if [ ! -f "$recfile" ]; then
        echo "Error: recfile '$recfile' does not exist." >&2
        exit 1
    fi

    # ---- Determine record type --------------------------------------------
    local available_types
    available_types=$(list_record_types "$recfile")

    if [ -z "$rectype" ]; then
        if [ -z "$available_types" ]; then
            echo "No %rec: types found. This recfile uses free-form fields." >&2
        else
            local -a display_list
            local -a name_list
            display_list=()
            name_list=()

            local _line
            while IFS= read -r _line; do
                [ -z "$_line" ] && continue
                display_list+=("$_line")
                local plain="${_line%% \[count:*}"
                name_list+=("$plain")
            done <<EOF
$available_types
EOF

            local count="${#name_list[@]}"

            if [ "$count" -eq 1 ]; then
                local only_type="${name_list[0]}"
                echo "Only one record type found: '$only_type'" >&2
                printf "Press Enter to use it, or type a different type: " >&2
                IFS= read -r rectype
                [ -z "$rectype" ] && rectype="$only_type"

            else
                # ----- Multiple types: single-keypress numbered menu -------
                echo "Available record types:" >&2
                local i=1
                local label
                for label in "${display_list[@]}"; do
                    printf "  %d) %s\n" "$i" "$label" >&2
                    i=$((i + 1))
                done

                while true; do
                    # ### CHANGED: -n1 reads exactly one character without
                    # requiring Enter.  -s suppresses echo so we can echo
                    # the digit ourselves on the same line for clarity.
                    printf "Select a record type (1-%d): " "$count" >&2
                    local choice
                    IFS= read -r -n1 -s choice
                    # Echo the character the user pressed so they can see it.
                    printf '%s\n' "$choice" >&2

                    # Blank (Enter pressed directly) → re-prompt.
                    if [ -z "$choice" ]; then
                        echo "  -> Please press a digit between 1 and $count." >&2
                        continue
                    fi

                    # Reject non-digits immediately.
                    case "$choice" in
                    *[!0-9]*)
                        echo "  -> Please press a digit between 1 and $count." >&2
                        continue
                        ;;
                    esac

                    if [ "$choice" -ge 1 ] && [ "$choice" -le "$count" ]; then
                        rectype="${name_list[$((choice - 1))]}"
                        break
                    else
                        echo "  -> Out of range. Choose between 1 and $count." >&2
                    fi
                done
            fi

            echo "Selected record type: $rectype" >&2
        fi
    fi

    # ---- Load the descriptor block ----------------------------------------
    local block=""
    if [ -n "$rectype" ]; then
        block=$(get_descriptor_block "$recfile" "$rectype")
        if [ -z "$block" ]; then
            echo "Warning: no descriptor block found for type '$rectype'." >&2
        fi
    fi

    # ---- Gather field rules -----------------------------------------------
    local mandatory_fields allowed_fields
    mandatory_fields=$(extract_fields "$block" "%mandatory:")
    allowed_fields=$(extract_fields "$block" "%allowed:")

    echo "" >&2
    echo "----- Building a new record -----" >&2
    [ -n "$rectype" ] && echo "Record type: $rectype" >&2
    echo "" >&2

    local -a args
    args=()

    local prompted=""

    # ---- 1. Prompt for ALLOWED fields (if defined) ------------------------
    if [ -n "$allowed_fields" ]; then
        echo "== Allowed fields ==" >&2
        local f
        for f in $allowed_fields; do
            # ### CHANGED: Skip the %rec: type-name field.  When -t is passed
            # to recins it writes the %rec: line itself; supplying it again as
            # a -f/-v pair produces a malformed record with a duplicate or
            # misplaced %rec: field.
            if [ -n "$rectype" ] && [ "$f" = "$rectype" ]; then
                continue
            fi

            local mand="no"
            in_list "$f" $mandatory_fields && mand="yes"
            local info ftype label
            info=$(get_field_type_info "$block" "$f")
            ftype="${info%%"$TAB"*}" # everything before the tab  -> resolved type
            label="${info#*"$TAB"}"  # everything after the tab   -> display label

            local val
            val=$(prompt_field "$f" "$mand" "$ftype" "$label")
            if [ -n "$val" ]; then
                args+=(-f "$f" -v "$val")
            fi
            prompted="$prompted $f"
        done
    fi

    # ---- 2. Prompt for any MANDATORY fields not in the allowed list -------
    if [ -n "$mandatory_fields" ]; then
        local f
        local printed_header="no"
        for f in $mandatory_fields; do
            if ! in_list "$f" $prompted; then
                if [ "$printed_header" = "no" ]; then
                    echo "== Mandatory fields ==" >&2
                    printed_header="yes"
                fi
                local info ftype label
                info=$(get_field_type_info "$block" "$f")
                ftype="${info%%"$TAB"*}" # everything before the tab  -> resolved type
                label="${info#*"$TAB"}"  # everything after the tab   -> display label

                local val
                val=$(prompt_field "$f" "$mand" "$ftype" "$label")
                args+=(-f "$f" -v "$val")
                prompted="$prompted $f"
            fi
        done
    fi

    # ---- 3. Prompt for free-form / user-defined fields --------------------
    # ### CHANGED: Only offer free-form entry when no %allowed: constraint
    # exists for this record type.  If %allowed: is defined, recins will
    # reject any field not listed there, so presenting the prompt would
    # only allow the user to produce a record that fails on insert.
    if [ -z "$allowed_fields" ]; then
        echo "" >&2
        echo "== Additional fields ==" >&2
        echo "Enter a field name (or press Enter / type 'done' to finish):" >&2
        while true; do
            printf "Field name: " >&2
            local newfield
            IFS= read -r newfield
            case "$newfield" in
            "" | done | DONE | q | quit) break ;;
            esac

            local info ftype label
            info=$(get_field_type_info "$block" "$newfield")
            ftype="${info%%"$TAB"*}" # everything before the tab  -> resolved type
            label="${info#*"$TAB"}"  # everything after the tab   -> display label

            local val
            val=$(prompt_field "$newfield" "$mand" "$ftype" "$label")

            args+=(-f "$newfield" -v "$val")
        done
    else
        echo "" >&2
        echo "== Additional fields ==" >&2
        echo "(Skipped: this record type has %allowed: constraints.)" >&2
    fi

    # ---- Sanity check: did we collect anything? ---------------------------
    if [ "${#args[@]}" -eq 0 ]; then
        echo "No fields entered. Aborting." >&2
        exit 1
    fi

    # ---- Build and run recins ---------------------------------------------
    echo "" >&2
    echo "Inserting record..." >&2

    local status
    if [ -n "$rectype" ]; then
        recins -t "$rectype" "${args[@]}" "$recfile"
        status=$?
    else
        recins "${args[@]}" "$recfile"
        status=$?
    fi

    if [ "$status" -eq 0 ]; then
        echo "Record inserted successfully into '$recfile'." >&2
    else
        echo "recins failed (exit code $status)." >&2
        exit "$status"
    fi

}

main "$@"
