#!/usr/bin/env bash
set -euo pipefail

# === himi ===
# Memorable secret generator with configurable word lists and templates.
#
# Usage:
#   himi rebuild           Rebuild pool from enabled lists
#   himi                   Generate secret (default template) → clipboard
#   himi <template>        Generate secret (named template) → clipboard
#   himi -o [template]     Output to stdout + clipboard
#   himi -q [template]     Output to stdout only (no clipboard)
#   himi -n N              Generate N secrets
#   himi entropy [name]    Show entropy for template(s)
#   himi -h                Show help

readonly VERSION="1.1.0"
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi"
readonly CONFIG_FILE="${CONFIG_DIR}/config.sh"
readonly LISTS_DIR="${CONFIG_DIR}/lists"
readonly POOL_DIR="${CONFIG_DIR}/pool"
readonly POOL_FILE="${POOL_DIR}/pool.txt"
readonly POOL_SUM_FILE="${POOL_DIR}/.pool.sha256"

# Bump when config.sh gains new options; configs declaring an older
# CONFIG_VERSION (or none) get a nag until merged and re-stamped
readonly CONFIG_SCHEMA_VERSION=2

# Generic charset token: {name:N} or {name:N-M}
readonly TOKEN_RE='\{([a-z][a-z0-9_]*):([0-9]+)(-([0-9]+))?\}'


# ------------------------------------------------------------------------------
# Defaults (overridden by config.sh)
# ------------------------------------------------------------------------------
ALPHA_CHARSET='0123456789ABCDEFGHJKMNPQRSTVWXYZ'  # Crockford Base32 (32 chars, 5 bits)
SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679'           # Ultra-safe (24 chars, 4.6 bits) - no B/8, 0/O, 1/I/L, 5/S
NUM_CHARSET='0123456789'
SPECIAL_CHARSET='!?@#%~=+'
WORD_MIN_LENGTH=4
WORD_MAX_LENGTH=8

# Custom charsets: "name|characters". Names are lowercase [a-z][a-z0-9_]*.
# Entries here override the built-in four (alpha, safe, num, special).
CHARSETS=()

TEMPLATES=(
  "default|{word}-{word}-{alpha:5}"
  "easy|{word}-{alpha:3}-{num:3}"
  "secure|{word}-{word}-{alpha:5}-{word}-{alpha:5}{special:1}"
  "pin|{num:6}"
  "diceware|{word}-{word}-{word}-{word}-{word}"
)

DEFAULT_TEMPLATE="default"

# ------------------------------------------------------------------------------
# Colors (disabled if not a terminal)
# ------------------------------------------------------------------------------
if [[ -t 1 ]]; then
  readonly RED='\033[0;31m'
  readonly GREEN='\033[0;32m'
  readonly YELLOW='\033[0;33m'
  readonly DIM='\033[0;90m'
  readonly BOLD='\033[1m'
  readonly NC='\033[0m'
else
  readonly RED='' GREEN='' YELLOW='' DIM='' BOLD='' NC=''
fi

# Output helpers
err()   { printf "${RED}error:${NC} %s\n" "$1" >&2; }
ok()    { printf "${GREEN}✓${NC} %s\n" "$1" >&2; }
warn()  { printf "${YELLOW}!${NC} %s\n" "$1" >&2; }
dim()   { printf "${DIM}%s${NC}" "$1"; }

# ------------------------------------------------------------------------------
# Platform detection
# ------------------------------------------------------------------------------
detect_platform() {
  case "$(uname -s)" in
    Darwin*) PLATFORM="macos" ;;
    Linux*)  PLATFORM="linux" ;;
    *)       PLATFORM="unknown" ;;
  esac
}

# ------------------------------------------------------------------------------
# Cross-platform shuffle (requires GNU coreutils)
# ------------------------------------------------------------------------------
shuffle_lines() {
  if command -v shuf &>/dev/null; then
    shuf "$@"
  elif command -v gshuf &>/dev/null; then
    gshuf "$@"
  else
    err "GNU coreutils required"
    if [[ "$PLATFORM" == "macos" ]]; then
      printf "  Install with: brew install coreutils\n" >&2
    else
      printf "  Install with: sudo apt install coreutils (or equivalent)\n" >&2
    fi
    exit 1
  fi
}

# ------------------------------------------------------------------------------
# Refuse to trust files that aren't ours or that others can write to
# (ssh-config style check; the config file is sourced = code execution)
# ------------------------------------------------------------------------------
require_safe_file() {
  local f="$1"
  [[ -e "$f" ]] || return 0

  if [[ ! -O "$f" ]]; then
    err "$f is not owned by you — refusing to use it"
    return 1
  fi

  local mode
  case "$PLATFORM" in
    macos) mode=$(stat -f '%Lp' "$f") ;;
    *)     mode=$(stat -c '%a' "$f") ;;
  esac

  if (( (8#$mode & 8#022) != 0 )); then
    err "$f is group/world-writable (mode $mode) — refusing to use it"
    err "fix with: chmod go-w '$f'"
    return 1
  fi
}

# ------------------------------------------------------------------------------
# Load user config if exists
# ------------------------------------------------------------------------------
load_config() {
  if [[ -f "$CONFIG_FILE" ]]; then
    require_safe_file "$CONFIG_DIR"  || exit 1
    require_safe_file "$CONFIG_FILE" || exit 1
    source "$CONFIG_FILE"
    validate_charsets
    check_config_version
  fi
}

# ------------------------------------------------------------------------------
# Nag (don't fail) when the user's config predates the current schema —
# new options like CHARSETS won't exist in it. Silenced by merging the new
# default config and setting CONFIG_VERSION to the current schema version.
# ------------------------------------------------------------------------------
check_config_version() {
  local have="${CONFIG_VERSION:-0}"
  [[ "$have" =~ ^[0-9]+$ ]] || have=0

  if (( have < CONFIG_SCHEMA_VERSION )); then
    warn "config.sh is from an older himi (config v${have} < v${CONFIG_SCHEMA_VERSION}) — new options are missing"
    warn "merge the repo's config.sh (or ${CONFIG_FILE}.new if the installer left one),"
    warn "then set CONFIG_VERSION=${CONFIG_SCHEMA_VERSION} in it to silence this"
  elif (( have > CONFIG_SCHEMA_VERSION )); then
    warn "config.sh declares v${have} but this himi understands v${CONFIG_SCHEMA_VERSION} — update the binary?"
  fi
}

# ------------------------------------------------------------------------------
# Sanity-check user-defined charsets (run once after config load)
# ------------------------------------------------------------------------------
validate_charsets() {
  local entry name set dupes
  for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
    IFS='|' read -r name set <<< "$entry"
    if [[ ! "$name" =~ ^[a-z][a-z0-9_]*$ ]]; then
      warn "charset '$name' ignored: names must match [a-z][a-z0-9_]*"
      continue
    fi
    if [[ -z "$set" ]]; then
      warn "charset '$name' is empty"
      continue
    fi
    dupes=$(printf '%s' "$set" | grep -o . | sort | uniq -d | tr -d '\n')
    if [[ -n "$dupes" ]]; then
      warn "charset '$name' has duplicate chars ($dupes): distribution skewed, entropy overstated"
    fi
  done
}

# ------------------------------------------------------------------------------
# Look up a charset by name: user CHARSETS first, then built-ins
# ------------------------------------------------------------------------------
get_charset() {
  local name="$1" entry cname cset
  for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
    IFS='|' read -r cname cset <<< "$entry"
    if [[ "$cname" == "$name" ]]; then
      printf '%s' "$cset"
      return 0
    fi
  done
  case "$name" in
    alpha)   printf '%s' "$ALPHA_CHARSET" ;;
    safe)    printf '%s' "$SAFE_CHARSET" ;;
    num)     printf '%s' "$NUM_CHARSET" ;;
    special) printf '%s' "$SPECIAL_CHARSET" ;;
    *)       return 1 ;;
  esac
}

# ------------------------------------------------------------------------------
# Ensure directories exist
# ------------------------------------------------------------------------------
init_dirs() {
  mkdir -p "$LISTS_DIR" "$POOL_DIR"
}

# ------------------------------------------------------------------------------
# Filter words: length range + de-duplicate derivatives
# ------------------------------------------------------------------------------
filter_words() {
  local min_len="${1:-$WORD_MIN_LENGTH}"
  local max_len="${2:-$WORD_MAX_LENGTH}"

  grep -E "^[a-z]{${min_len},${max_len}}$" \
    | sort -u \
    | awk '{
        if (prev && index($0, prev) == 1 && length($0) - length(prev) <= 4) next
        prev = $0; print
      }'
}

# ------------------------------------------------------------------------------
# Add: import and enable a local word list
# ------------------------------------------------------------------------------
cmd_add() {
  local file="${1:-}"

  if [[ -z "$file" ]]; then
    err "usage: himi add <wordlist-file>"
    return 1
  fi

  if [[ ! -f "$file" ]]; then
    err "file not found: $file"
    return 1
  fi

  init_dirs

  # Derive name from filename (strip path and extension, sanitize to [a-z0-9_-])
  local name
  name=$(basename "$file" | sed 's/\.[^.]*$//' | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9_-')

  if [[ -z "$name" ]]; then
    err "cannot derive a valid list name from '$file'"
    return 1
  fi

  local listfile="${LISTS_DIR}/${name}.txt"

  # Clean: lowercase, alpha only, sort, dedupe
  tr 'A-Z' 'a-z' < "$file" \
    | grep -E '^[a-z]+$' \
    | sort -u \
    > "$listfile"

  local count
  count=$(wc -l < "$listfile")
  ok "added $name ($count words)"

  # Enable by default
  cmd_enable "$name"
}

# ------------------------------------------------------------------------------
# Remove: delete list and disable it
# ------------------------------------------------------------------------------
cmd_remove() {
  if [[ $# -eq 0 ]]; then
    err "usage: himi remove <list>..."
    return 1
  fi

  init_dirs
  local had_errors=0

  for name in "$@"; do
    local listfile="${LISTS_DIR}/${name}.txt"
    local linkfile="${POOL_DIR}/${name}.txt"

    if [[ ! -f "$listfile" ]]; then
      warn "list '$name' not found"
      had_errors=1
      continue
    fi

    # Remove symlink if it's currently enabled
    [[ -L "$linkfile" ]] && rm "$linkfile"

    # Remove the actual list
    rm "$listfile"
    ok "removed $name"
  done

  rebuild_pool
  return $had_errors
}

# ------------------------------------------------------------------------------
# Enable: symlink list(s) into pool/
# ------------------------------------------------------------------------------
enable_one() {
  local name="$1"
  local listfile="${LISTS_DIR}/${name}.txt"
  local linkfile="${POOL_DIR}/${name}.txt"

  if [[ ! -f "$listfile" ]]; then
    err "list '$name' not found"
    return 1
  fi

  if [[ -L "$linkfile" ]]; then
    warn "'$name' already enabled"
    return 0
  fi

  ln -s "../lists/${name}.txt" "$linkfile"
  ok "enabled $name"
}

cmd_enable() {
  if [[ $# -eq 0 ]]; then
    err "usage: himi enable <list>... | all"
    return 1
  fi

  init_dirs

  local had_errors=0
  for name in "$@"; do
    if [[ "$name" == "all" ]]; then
      local f
      for f in "$LISTS_DIR"/*.txt; do
        [[ -f "$f" ]] || continue
        enable_one "$(basename "$f" .txt)" || had_errors=1
      done
    else
      enable_one "$name" || had_errors=1
    fi
  done

  rebuild_pool
  return $had_errors
}

# ------------------------------------------------------------------------------
# Disable: remove symlink(s) from pool/
# ------------------------------------------------------------------------------
disable_one() {
  local name="$1"
  local linkfile="${POOL_DIR}/${name}.txt"

  if [[ ! -L "$linkfile" ]]; then
    warn "'$name' not enabled"
    return 0
  fi

  rm "$linkfile"
  ok "disabled $name"
}

cmd_disable() {
  if [[ $# -eq 0 ]]; then
    err "usage: himi disable <list>... | all"
    return 1
  fi

  init_dirs

  local had_errors=0
  for name in "$@"; do
    if [[ "$name" == "all" ]]; then
      local f
      for f in "$POOL_DIR"/*.txt; do
        [[ -L "$f" ]] || continue
        disable_one "$(basename "$f" .txt)" || had_errors=1
      done
    else
      disable_one "$name" || had_errors=1
    fi
  done

  rebuild_pool
  return $had_errors
}

# ------------------------------------------------------------------------------
# Rebuild: regenerate pool.txt from enabled lists
# ------------------------------------------------------------------------------
cmd_rebuild() {
  init_dirs
  rebuild_pool
}

# ------------------------------------------------------------------------------
# Pool manifest: detect pool.txt changes made outside 'himi rebuild'
# (catches accidental edits, sync corruption, casual tampering; an attacker
# with write access to $HOME can defeat this, but they can also edit config.sh)
# ------------------------------------------------------------------------------
sha256_file() {
  if command -v sha256sum &>/dev/null; then
    sha256sum "$1" | awk '{print $1}'
  elif command -v shasum &>/dev/null; then
    shasum -a 256 "$1" | awk '{print $1}'
  else
    return 1
  fi
}

write_pool_manifest() {
  local sum
  sum=$(sha256_file "$POOL_FILE") || return 0  # no hash tool: skip quietly
  printf '%s\n' "$sum" > "$POOL_SUM_FILE"
}

verify_pool_manifest() {
  [[ -f "$POOL_SUM_FILE" ]] || return 0  # pre-1.1 install: no manifest yet
  local want have
  want=$(<"$POOL_SUM_FILE")
  have=$(sha256_file "$POOL_FILE") || return 0
  if [[ "$want" != "$have" ]]; then
    err "pool.txt changed outside 'himi rebuild' (checksum mismatch)"
    warn "if this was you, run 'himi rebuild'; if not, inspect $POOL_DIR and $LISTS_DIR"
    return 1
  fi
}

# ------------------------------------------------------------------------------
# Rebuild pool from enabled lists (symlinks in pool/)
# ------------------------------------------------------------------------------
rebuild_pool() {
  local before=0
  [[ -f "$POOL_FILE" ]] && before=$(wc -l < "$POOL_FILE")

  # Only include symlinked files (enabled lists), exclude pool.txt itself.
  # find -exec is whitespace-safe (list names can contain '-' and '_' only,
  # but belt and suspenders).
  local enabled_count
  enabled_count=$(find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' 2>/dev/null | wc -l)

  if (( enabled_count == 0 )); then
    warn "no lists enabled"
    > "$POOL_FILE"
    write_pool_manifest
    return
  fi

  find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' -exec cat {} + \
    | filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \
    > "$POOL_FILE"

  write_pool_manifest

  local after
  after=$(wc -l < "$POOL_FILE")
  local diff=$((after - before))

  if [[ $before -eq 0 ]]; then
    ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)")"
  elif [[ $diff -ne 0 ]]; then
    local sign="+"
    [[ $diff -lt 0 ]] && sign=""
    ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)") [${sign}${diff}]"
  fi

  if [[ "$after" -gt 0 && "$after" -lt 256 ]]; then
    warn "pool has only $after words ($(calc_log2 "$after") bits/word) — secrets will be weak"
    warn "enable more lists with 'himi enable' or add lists with 'himi add'"
  fi
}

# ------------------------------------------------------------------------------
# List: show all lists and enabled status
# ------------------------------------------------------------------------------
cmd_list() {
  init_dirs

  if ! ls "${LISTS_DIR}"/*.txt &>/dev/null; then
    warn "no word lists found — run 'himi add <file>'"
    return 0
  fi

  printf "    %-16s %7s\n" "LIST" "WORDS"
  printf "    %-16s %7s\n" "────────────────" "───────"

  for f in "${LISTS_DIR}"/*.txt; do
    local name count marker
    name=$(basename "$f" .txt)
    count=$(wc -l < "$f")

    if [[ -L "${POOL_DIR}/${name}.txt" ]]; then
      marker="${GREEN}●${NC}"
    else
      marker="${DIM}○${NC}"
    fi

    printf " %b  %-16s %7d\n" "$marker" "$name" "$count"
  done

  printf "    %-16s %7s\n" "────────────────" "───────"

  if [[ -f "$POOL_FILE" ]]; then
    local pool_count
    pool_count=$(wc -l < "$POOL_FILE")
    if [[ "$pool_count" -gt 0 ]]; then
      printf "    %-16s %7d  ${DIM}(%.1f bits/word)${NC}\n" "pool" "$pool_count" "$(calc_log2 "$pool_count")"
    else
      printf "    %-16s %7d  ${DIM}(empty)${NC}\n" "pool" "0"
    fi
  fi
}

# ------------------------------------------------------------------------------
# Uninstall: remove himi and all data
# ------------------------------------------------------------------------------
cmd_uninstall() {
  read -p "Are you sure you want to remove himi and all custom wordlists? (y/N) " -n 1 -r
  echo
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    return 1
  fi

  # 1. Clean up Shell Profiles
  local profiles=(".bashrc" ".zshrc" ".bash_profile" ".profile")
  local start_marker="# >>> himi initialize >>>"
  local end_marker="# <<< himi initialize <<<"

  for p in "${profiles[@]}"; do
    if [[ -f "$HOME/$p" ]]; then
      # Uses sed to delete everything between the markers (inclusive)
      if grep -q "$start_marker" "$HOME/$p"; then
        if [[ "$OSTYPE" == "darwin"* ]]; then
          # macOS requires the extension to be attached to the -i flag
          sed -i '.bak' "/$start_marker/,/$end_marker/d" "$HOME/$p"
        else
          # Linux/GNU
          sed -i.bak "/$start_marker/,/$end_marker/d" "$HOME/$p"
        fi
        ok "Cleaned up $p (backup created as $p.bak)"
      fi
    fi
  done

  # 2. Remove completions
  rm -f "${HOME}/.zfunc/_himi"
  rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/himi"

  # 3. Remove data and binary
  rm -rf "$CONFIG_DIR"
  ok "Removed configuration and wordlists."

  # Final step: Binary deletes itself
  local self
  self=$(command -v himi)
  rm -f "$self"

  ok "himi has been uninstalled."
}

# ------------------------------------------------------------------------------
# Calculate log base 2 using awk
# ------------------------------------------------------------------------------
calc_log2() {
  awk -v n="$1" 'BEGIN { printf "%.2f", log(n)/log(2) }'
}

# ------------------------------------------------------------------------------
# Get charset length (characters, not bytes — multibyte-safe)
# ------------------------------------------------------------------------------
charset_len() {
  local s="$1"
  printf '%s' "${#s}"
}

# ------------------------------------------------------------------------------
# Count occurrences of a fixed token in a string (pure bash, no pipelines)
# ------------------------------------------------------------------------------
count_token() {
  local s="$1" t="$2" stripped
  stripped="${s//"$t"/}"
  printf '%d' $(( (${#s} - ${#stripped}) / ${#t} ))
}

# ------------------------------------------------------------------------------
# Calculate entropy for a template string
# ------------------------------------------------------------------------------
calc_entropy() {
  local template="$1"
  local entropy=0
  local pool_size word_bits

  pool_size=$(wc -l < "$POOL_FILE" 2>/dev/null || echo 0)
  [[ "$pool_size" -eq 0 ]] && { echo "0"; return; }

  word_bits=$(calc_log2 "$pool_size")

  # Count word variants (most specific first)
  # {word!} = pool + 1 bit (2 cases)
  local word_bang_count
  word_bang_count=$(count_token "$template" '{word!}')
  if [[ "$word_bang_count" -gt 0 ]]; then
    entropy=$(awk -v e="$entropy" -v w="$word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + 1) }')
    template="${template//\{word!\}/}"
  fi

  # {Word!} = pool + log2(3) bits (3 cases)
  local Word_bang_count
  Word_bang_count=$(count_token "$template" '{Word!}')
  if [[ "$Word_bang_count" -gt 0 ]]; then
    entropy=$(awk -v e="$entropy" -v w="$Word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + log(3)/log(2)) }')
    template="${template//\{Word!\}/}"
  fi

  # {WORD}, {Word}, {word} = pool bits only (no case entropy)
  local WORD_count Word_count word_count
  WORD_count=$(count_token "$template" '{WORD}')
  template="${template//\{WORD\}/}"

  Word_count=$(count_token "$template" '{Word}')
  template="${template//\{Word\}/}"

  word_count=$(count_token "$template" '{word}')

  local total_plain_words=$(( WORD_count + Word_count + word_count ))
  if [[ "$total_plain_words" -gt 0 ]]; then
    entropy=$(awk -v e="$entropy" -v w="$total_plain_words" -v b="$word_bits" 'BEGIN { print e + (w * b) }')
  fi

  # Generic charset tokens: {name:N} or {name:N-M}
  # Entropy = log2( sum of charset_size^L for L in N..M )
  while [[ "$template" =~ $TOKEN_RE ]]; do
    local token="${BASH_REMATCH[0]}"
    local cname="${BASH_REMATCH[1]}"
    local lmin="${BASH_REMATCH[2]}"
    local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
    lmin=$(( 10#$lmin ))
    lmax=$(( 10#$lmax ))

    local charset
    if charset=$(get_charset "$cname") && (( lmin <= lmax )); then
      local cs_size bits
      cs_size=$(charset_len "$charset")
      bits=$(awk -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_size" 'BEGIN {
        t = 0
        for (l = lmin; l <= lmax; l++) t += cs ^ l
        printf "%.4f", log(t) / log(2)
      }')
      entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
    fi
    # Unknown charsets and bad ranges contribute 0 bits; generation will warn.
    template="${template/"$token"/}"
  done

  printf "%.1f" "$entropy"
}

# ------------------------------------------------------------------------------
# Pick N random words from pool
# ------------------------------------------------------------------------------
pick_words() {
  local n="${1:-1}"
  shuffle_lines -n "$n" "$POOL_FILE"
}

# ------------------------------------------------------------------------------
# Uniform random integer 0..n-1 from /dev/urandom
# (replaces bash $RANDOM, which is a weak PRNG; modulo bias for the n=2,3
# used here is ≤ 0.002% on 16 bits — negligible)
# ------------------------------------------------------------------------------
rand_int() {
  local n="$1" r
  r=$(od -An -N2 -tu2 /dev/urandom | tr -d ' \n')
  printf '%d' $(( r % n ))
}

# ------------------------------------------------------------------------------
# Generate random string from charset
# Indexes into the charset with rejection sampling: uniform, no modulo bias,
# no tr metacharacter pitfalls, no SIGPIPE games with pipefail.
# ------------------------------------------------------------------------------
rand_chars() {
  local charset="$1"
  local count="$2"
  local cs_len="${#charset}"
  local out="" limit b idx

  if (( cs_len < 1 || cs_len > 256 )); then
    err "charset must have 1-256 characters (got $cs_len)"
    return 1
  fi

  # Largest multiple of cs_len that fits in a byte; reject bytes above it
  limit=$(( 256 / cs_len * cs_len ))

  while (( ${#out} < count )); do
    for b in $(od -An -N64 -tu1 /dev/urandom); do
      if (( b < limit )); then
        idx=$(( b % cs_len ))
        out+="${charset:idx:1}"
      fi
      (( ${#out} >= count )) && break
    done
  done

  printf '%s' "${out:0:count}"
}

# ------------------------------------------------------------------------------
# Pick a length in [lmin, lmax], weighted by charset_size^L so that every
# possible output string is equally likely (uniform length would skew toward
# short strings and tank the min-entropy)
# ------------------------------------------------------------------------------
pick_length() {
  local lmin="$1" lmax="$2" cs_len="$3"

  if (( lmin == lmax )); then
    printf '%s' "$lmin"
    return 0
  fi

  local r
  r=$(od -An -N4 -tu4 /dev/urandom | tr -d ' \n')

  awk -v r="$r" -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_len" 'BEGIN {
    total = 0
    for (l = lmin; l <= lmax; l++) total += cs ^ l
    x = (r / 4294967296.0) * total
    acc = 0
    for (l = lmin; l <= lmax; l++) {
      acc += cs ^ l
      if (x < acc) { print l; exit }
    }
    print lmax
  }'
}

# ------------------------------------------------------------------------------
# Portable case conversion (works on bash 3.2/macOS)
# ------------------------------------------------------------------------------
to_upper() { tr '[:lower:]' '[:upper:]'; }
to_lower() { tr '[:upper:]' '[:lower:]'; }
to_title() {
  local w
  read -r w
  printf "%s%s" "$(printf '%s' "${w:0:1}" | to_upper)" "$(printf '%s' "${w:1}" | to_lower)"
}

# ------------------------------------------------------------------------------
# Expand a template into a secret
# ------------------------------------------------------------------------------
expand_template() {
  local template="$1"
  local result="$template"
  local word

  # Process word tokens (most specific first to avoid partial matches)

  # {word!} - random lower/UPPER (+1 bit)
  while [[ "$result" =~ \{word!\} ]]; do
    word=$(pick_words 1)
    if (( $(rand_int 2) )); then
      word=$(printf '%s' "$word" | to_upper)
    fi
    result="${result/\{word!\}/"$word"}"
  done

  # {Word!} - random lower/Title/UPPER (+1.58 bits)
  while [[ "$result" =~ \{Word!\} ]]; do
    word=$(pick_words 1)
    case "$(rand_int 3)" in
      1) word=$(printf '%s' "$word" | to_title) ;;
      2) word=$(printf '%s' "$word" | to_upper) ;;
    esac
    result="${result/\{Word!\}/"$word"}"
  done

  # {WORD} - all upper
  while [[ "$result" =~ \{WORD\} ]]; do
    word=$(pick_words 1 | to_upper)
    result="${result/\{WORD\}/"$word"}"
  done

  # {Word} - title case
  while [[ "$result" =~ \{Word\} ]]; do
    word=$(pick_words 1 | to_title)
    result="${result/\{Word\}/"$word"}"
  done

  # {word} - all lower (default)
  while [[ "$result" =~ \{word\} ]]; do
    word=$(pick_words 1)
    result="${result/\{word\}/"$word"}"
  done

  # Generic charset tokens: {name:N} or {name:N-M}
  while [[ "$result" =~ $TOKEN_RE ]]; do
    local token="${BASH_REMATCH[0]}"
    local cname="${BASH_REMATCH[1]}"
    local lmin="${BASH_REMATCH[2]}"
    local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
    lmin=$(( 10#$lmin ))
    lmax=$(( 10#$lmax ))

    local charset n chars
    if ! charset=$(get_charset "$cname") || (( lmin > lmax )); then
      # Unknown charset or bad range: neutralize the opening brace so the
      # loop can move on; restored below so the leftover-token warning fires.
      result="${result/"$token"/$'\x01'${token:1}}"
      continue
    fi

    n=$(pick_length "$lmin" "$lmax" "$(charset_len "$charset")")
    chars=$(rand_chars "$charset" "$n")
    # Quoted replacement: keeps bash 5.2's patsub_replacement from
    # interpreting '&' in user charsets as "the matched text"
    result="${result/"$token"/"$chars"}"
  done

  # Restore any neutralized tokens so they're visible (and warnable)
  result="${result//$'\x01'/\{}"

  printf "%s" "$result"
}

# ------------------------------------------------------------------------------
# Get template string by name
# ------------------------------------------------------------------------------
get_template() {
  local name="$1"
  for entry in "${TEMPLATES[@]}"; do
    IFS='|' read -r tname tpattern <<< "$entry"
    if [[ "$tname" == "$name" ]]; then
      printf "%s" "$tpattern"
      return 0
    fi
  done
  err "unknown template '$name'"
  return 1
}

# ------------------------------------------------------------------------------
# Copy to clipboard (cross-platform)
# Consumes stdin in all cases; NEVER leaks the secret to stdout (the caller
# decides what to print — this used to double-print on headless boxes)
# ------------------------------------------------------------------------------
to_clipboard() {
  case "$PLATFORM" in
    macos)
      pbcopy
      ;;
    linux)
      if command -v xclip &>/dev/null; then
        xclip -selection clipboard
      elif command -v xsel &>/dev/null; then
        xsel --clipboard --input
      elif command -v wl-copy &>/dev/null; then
        wl-copy
      else
        cat > /dev/null
        return 1
      fi
      ;;
    *)
      cat > /dev/null
      return 1
      ;;
  esac
}

# ------------------------------------------------------------------------------
# Show entropy for templates
# ------------------------------------------------------------------------------
cmd_entropy() {
  local specific="${1:-}"

  if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
    err "pool empty — run 'himi rebuild' first"
    return 1
  fi

  printf "%-12s %-40s %s\n" "TEMPLATE" "EXAMPLE" "ENTROPY"
  printf "%-12s %-40s %s\n" "--------" "-------" "-------"

  for entry in "${TEMPLATES[@]}"; do
    IFS='|' read -r name pattern <<< "$entry"

    # If specific template requested, skip others
    [[ -n "$specific" && "$name" != "$specific" ]] && continue

    local example entropy
    example=$(expand_template "$pattern")
    entropy=$(calc_entropy "$pattern")

    printf "%-12s %-40s ≈%s bits\n" "$name" "$example" "$entropy"
  done
}

# ------------------------------------------------------------------------------
# Config: open config file in editor
# ------------------------------------------------------------------------------
cmd_config() {
  local editor="${EDITOR:-${VISUAL:-vi}}"

  if [[ ! -f "$CONFIG_FILE" ]]; then
    err "config not found: $CONFIG_FILE"
    return 1
  fi

  local before after
  before=$(cksum "$CONFIG_FILE")
  "$editor" "$CONFIG_FILE"
  after=$(cksum "$CONFIG_FILE")

  if [[ "$before" != "$after" ]]; then
    ok "config changed — rebuilding pool"
    load_config
    rebuild_pool
  fi
}

# ------------------------------------------------------------------------------
# Test: one-off pattern test with entropy comparison
# ------------------------------------------------------------------------------
cmd_test() {
  local pattern="${1:-}"

  if [[ -z "$pattern" ]]; then
    err "usage: himi test '{pattern}'"
    printf "\n  Example: himi test '{word!}-{num:2}-{word!}'\n"
    return 1
  fi

  if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
    err "pool empty — run 'himi rebuild' first"
    return 1
  fi

  local entropy
  entropy=$(calc_entropy "$pattern")

  printf "\n"
  printf "  ${BOLD}Pattern:${NC}  %s\n" "$pattern"
  printf "  ${BOLD}Entropy:${NC}  ≈%s bits\n" "$entropy"
  printf "\n"
  printf "  ${BOLD}Examples:${NC}\n"
  local example leftovers=""
  for ((i = 0; i < 5; i++)); do
    example=$(expand_template "$pattern")
    [[ "$example" == *'{'* && "$example" == *'}'* ]] && leftovers="true"
    printf "    %s\n" "$example"
  done
  printf "\n"

  if [[ -n "$leftovers" ]]; then
    warn "pattern contains unexpanded {token}s — unknown charset or typo? (0 bits counted for them)"
    printf "\n"
  fi

  # Show comparison with built-in templates
  printf "  ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY"
  printf "  ${DIM}%-12s %s${NC}\n" "────────────" "───────"
  printf "  ${BOLD}%-12s ≈%s bits${NC}  ← your pattern\n" "(test)" "$entropy"

  for entry in "${TEMPLATES[@]}"; do
    IFS='|' read -r tname tpattern <<< "$entry"
    local tent
    tent=$(calc_entropy "$tpattern")
    printf "  %-12s ≈%s bits\n" "$tname" "$tent"
  done
  printf "\n"
}

# ------------------------------------------------------------------------------
# Generate secrets
# ------------------------------------------------------------------------------
cmd_generate() {
  local template_name="${1:-$DEFAULT_TEMPLATE}"
  local count="${2:-1}"
  local use_stdout="${3:-false}"
  local use_clipboard="${4:-true}"

  if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
    err "pool empty — run 'himi rebuild' first"
    return 1
  fi

  # Refuse if pool.txt was modified behind himi's back
  verify_pool_manifest || return 1

  # Sanity check: ensure pool has enough unique words for meaningful entropy
  local unique_words
  unique_words=$(sort -u "$POOL_FILE" | wc -l)
  if [[ "$unique_words" -lt 256 ]]; then
    err "pool has only $unique_words unique words — refusing to generate (need ≥256)"
    warn "run 'himi rebuild' or 'himi enable' to fix"
    return 1
  fi

  local pattern
  pattern=$(get_template "$template_name") || return 1

  local secrets=""
  for ((i = 0; i < count; i++)); do
    [[ -n "$secrets" ]] && secrets+=$'\n'
    secrets+=$(expand_template "$pattern")
  done

  if [[ "$secrets" == *'{'* && "$secrets" == *'}'* ]]; then
    warn "output contains unexpanded {token}s — check the template ('himi test' helps)"
  fi

  local copied="false"
  if [[ "$use_clipboard" == "true" ]]; then
    if printf "%s" "$secrets" | to_clipboard; then
      ok "copied to clipboard"
      copied="true"
    else
      warn "clipboard unavailable (install xclip, xsel, or wl-copy) — printing instead"
    fi
  fi

  # Print if asked for stdout, or if the secret would otherwise be lost
  if [[ "$use_stdout" == "true" || "$copied" == "false" ]]; then
    printf "%s\n" "$secrets"
  fi
}

# ------------------------------------------------------------------------------
# Show help
# ------------------------------------------------------------------------------
cmd_help() {
  cat <<EOF
himi v${VERSION} — memorable secret generator

Usage:
  himi [template]           Generate → clipboard (default: $DEFAULT_TEMPLATE)
  himi -o [template]        Generate → clipboard + stdout
  himi -q [template]        Generate → stdout only (no clipboard; or HIMI_NO_CLIP=1)
  himi -n N [template]      Generate N secrets

  himi add <file>           Add and enable a word list
  himi enable <list>...     Enable word list(s), or 'all'
  himi disable <list>...    Disable word list(s), or 'all'
  himi list                 Show all lists and status
  himi rebuild              Rebuild pool from enabled lists

  himi entropy [name]       Show entropy for template(s)
  himi test '{pattern}'     Test a custom pattern
  himi config               Edit config in \$EDITOR
  himi uninstall            Remove himi and all data
  himi -h, --help           Show this help

Templates (configure in $CONFIG_FILE):
EOF
  for entry in "${TEMPLATES[@]}"; do
    IFS='|' read -r name pattern <<< "$entry"
    printf "  %-12s %s\n" "$name" "$pattern"
  done

  cat <<EOF

Tokens:
  {word}       Random word (lowercase)
  {WORD}       Random word (UPPERCASE)
  {Word}       Random word (Title Case)
  {word!}      Random word (lower or UPPER, +1 bit)
  {Word!}      Random word (lower/Title/UPPER, +1.58 bits)
  {NAME:N}     N chars from charset NAME
  {NAME:N-M}   N to M chars from charset NAME (weighted so every
               output is equally likely; entropy = log2 of total outcomes)

Charsets:
EOF
  printf "  %-12s %s\n" "alpha" "$ALPHA_CHARSET"
  printf "  %-12s %s\n" "safe" "$SAFE_CHARSET"
  printf "  %-12s %s\n" "num" "$NUM_CHARSET"
  printf "  %-12s %s\n" "special" "$SPECIAL_CHARSET"
  for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
    IFS='|' read -r name pattern <<< "$entry"
    printf "  %-12s %s\n" "$name" "$pattern"
  done

  cat <<EOF

Custom charsets: add "name|chars" entries to CHARSETS in config

Config: $CONFIG_FILE
Pool:   $POOL_FILE
EOF
}

# ------------------------------------------------------------------------------
# Main
# ------------------------------------------------------------------------------
main() {
  detect_platform
  load_config
  init_dirs

  # 1. Intercept top-level commands first
  case "${1:-}" in
    add|enable|disable|remove|rebuild|list|test|config|entropy|uninstall)
      local cmd="cmd_$1"
      shift
      "$cmd" "$@"  # Pass all remaining arguments correctly
      return $?
      ;;
  esac

  # 2. If it's not a command, parse generation flags and templates
  local use_stdout="false"
  local use_clipboard="true"
  local count=1
  local template_name=""

  [[ -n "${HIMI_NO_CLIP:-}" ]] && use_clipboard="false"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--stdout)
        use_stdout="true"
        shift
        ;;
      -q|--quiet)
        use_clipboard="false"
        shift
        ;;
      -n|--number)
        if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then
          err "-n requires a positive integer (got: '${2:-}')"
          return 1
        fi
        count="$2"
        shift 2
        ;;
      -h|--help|help)
        cmd_help
        return 0
        ;;
      -*)
        err "Unknown option: $1"
        cmd_help >&2
        return 1
        ;;
      *)
        if [[ -n "$template_name" ]]; then
           err "Unexpected argument: $1. Did you mean to use a command?"
           return 1
        fi
        template_name="$1"
        shift
        ;;
    esac
  done

  [[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE"
  cmd_generate "$template_name" "$count" "$use_stdout" "$use_clipboard"
}

main "$@"
