#!/usr/bin/env bash

# nag is a bash script for setting one-off or repeating alarms.

# MIT License
#
# Copyright (c) 2026 Lewis Wynne
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# Enforce Bash "strict mode".
set -o nounset
set -o errexit
set -o errtrace
set -o pipefail
trap 'echo "Aborting due to errexit on line $LINENO. Exit code: $?" >&2' ERR
IFS=$'\n\t'

_ME="$(basename "${0}")"
_VERSION="2026.14"

NAG_DIR="${NAG_DIR:-${HOME}/.local/share/nag}"
_ALARMS_FILE="${NAG_DIR}/alarms"
_LOCKFILE="${NAG_DIR}/alarms.lock"

# The command nag runs to execute its notifications.
NAG_CMD="${NAG_CMD:-notify-send}"

# Sound file to play when an alarm fires. Empty or missing file = no sound.
NAG_SOUND="${NAG_SOUND-/usr/share/sounds/freedesktop/stereo/bell.oga}"
_MUTE_FILE="${NAG_DIR}/mute"
_SNOOZED_FILE="${NAG_DIR}/snoozed"

# The default subcommand if no args are passed.
NAG_DEFAULT="${NAG_DEFAULT:-list}"

# The fallback subcommand if an arg is passed that is not defined.
_FALLBACK_COMMAND_IF_NO_MATCH="at"

# Usage:
#   _debug <command> <options>...
#
# Description:
#   Execute a command and print to standard error. The command is expected to
#   print a message and should typically be either `echo`, `printf`, or `cat`.
#
# Example:
#   _debug printf "Debug info. Variable: %s\\n" "$0"
__DEBUG_COUNTER=0
_debug() {
  if ((${_USE_DEBUG:-0}))
  then
    __DEBUG_COUNTER=$((__DEBUG_COUNTER+1))
    {
      # Prefix debug message with "bug (U+1F41B)"
      printf "🐛  %s " "${__DEBUG_COUNTER}"
      "${@}"
      printf "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――\\n"
    } 1>&2
  fi
}

# Usage:
#   _exit_1 <command>
#
# Description:
#   Exit with status 1 after executing the specified command with output
#   redirected to standard error. The command is expected to print a message
#   and should typically be either `echo`, `printf`, or `cat`.
_exit_1() {
  {
    printf "%s " "$(tput setaf 1)!$(tput sgr0)"
    "${@}"
  } 1>&2
  exit 1
}

# Usage:
#   _warn <command>
#
# Description:
#   Print the specified command with output redirected to standard error.
#   The command is expected to print a message and should typically be either
#   `echo`, `printf`, or `cat`.
_warn() {
  {
    printf "%s " "$(tput setaf 1)!$(tput sgr0)"
    "${@}"
  } 1>&2
}

# Usage:
#   _function_exists <name>
#
# Exit / Error Status:
#   0 (success, true) If function with <name> is defined in the current
#                     environment.
#   1 (error,  false) If not.
_function_exists() {
  [ "$(type -t "${1}")" == 'function' ]
}

# Usage:
#   _command_exists <name>
#
# Exit / Error Status:
#   0 (success, true) If a command with <name> is defined in the current
#                     environment.
#   1 (error,  false) If not.
_command_exists() {
  hash "${1}" 2>/dev/null
}

# Usage:
#   _contains <query> <list-item>...
#
# Exit / Error Status:
#   0 (success, true)  If the item is included in the list.
#   1 (error,  false)  If not.
#
# Examples:
#   _contains "${_query}" "${_list[@]}"
_contains() {
  local _query="${1:-}"
  shift

  if [[ -z "${_query}"  ]] ||
     [[ -z "${*:-}"     ]]
  then
    return 1
  fi

  for __element in "${@}"
  do
    [[ "${__element}" == "${_query}" ]] && return 0
  done

  return 1
}

# Usage:
#   _join <delimiter> <list-item>...
#
# Description:
#   Print a string containing all <list-item> arguments separated by
#   <delimeter>.
#
# Example:
#   _join "${_delimeter}" "${_list[@]}"
_join() {
  local _delimiter="${1}"
  shift
  printf "%s" "${1}"
  shift
  printf "%s" "${@/#/${_delimiter}}" | tr -d '[:space:]'
}

# Usage:
#   _blank <argument>
#
# Exit / Error Status:
#   0 (success, true)  If <argument> is not present or null.
#   1 (error,  false)  If <argument> is present and not null.
_blank() {
  [[ -z "${1:-}" ]]
}

# Usage:
#   _present <argument>
#
# Exit / Error Status:
#   0 (success, true)  If <argument> is present and not null.
#   1 (error,  false)  If <argument> is not present or null.
_present() {
  [[ -n "${1:-}" ]]
}

# Usage:
#   _interactive_input
#
# Exit / Error Status:
#   0 (success, true)  If the current input is interactive (eg, a shell).
#   1 (error,  false)  If the current input is stdin / piped input.
_interactive_input() {
  [[ -t 0 ]]
}

# Usage:
#   _piped_input
#
# Exit / Error Status:
#   0 (success, true)  If the current input is stdin / piped input.
#   1 (error,  false)  If the current input is interactive (eg, a shell).
_piped_input() {
  ! _interactive_input
}

# Usage:
#   _confirm_action <verb> <count> [<tag>]
#
# Prompts the user for confirmation before performing a bulk action on
# alarms. When <tag> is provided, the prompt includes the tag name.
# Skipped when -f (force/_YES) is set. In non-interactive mode, prints
# a message asking the caller to pass -f.
#
# Exit / Error Status:
#   0 (success, true)  If the action is confirmed.
#   1 (error,  false)  If the action is denied or non-interactive without -f.
_confirm_action() {
  local _verb="${1}" _count="${2}" _tag="${3:-}"

  (( _YES )) && return 0

  local _desc
  if [[ -n "${_tag}" ]]
  then
    _desc="${_count} alarm(s) tagged [${_tag}]"
  else
    _desc="${_count} alarm(s)"
  fi

  if _interactive_input
  then
    printf "%s %s? [y/N] " "${_verb}" "${_desc}"
    local _reply
    read -r _reply
    case "${_reply}" in
      [yY]*) return 0 ;;
      *)     return 1 ;;
    esac
  else
    printf "%s %s? Pass -f to confirm.\\n" "${_verb}" "${_desc}"
    return 1
  fi
}

# Usage:
#   describe <name> <description>
#   describe --get <name>
#
# Options:
#   --get  Print the description for <name> if one has been set.
describe() {
  _debug printf "describe() \${*}: %s\\n" "$@"
  [[ -z "${1:-}" ]] && _exit_1 printf "describe(): <name> required.\\n"

  if [[ "${1}" == "--get" ]]
  then
    [[ -z "${2:-}" ]] &&
      _exit_1 printf "describe(): <description> required.\\n"

    local _name="${2:-}"
    local _describe_var="___describe_${_name}"

    if [[ -n "${!_describe_var:-}" ]]
    then
      printf "%s\\n" "${!_describe_var}"
    else
      printf "No additional information for \`%s\`\\n" "${_name}"
    fi
  else
    if [[ -n "${2:-}" ]]
    then
      read -r -d '' "___describe_${1}" <<HEREDOC
${2}
HEREDOC
    else
      read -r -d '' "___describe_${1}" || true
    fi
  fi
}

# Iterate over options, breaking -ab into -a -b and --foo=bar into --foo bar
# also turns -- into --endopts to avoid issues with things like '-o-', the '-'
# should not indicate the end of options, but be an invalid option (or the
# argument to the option, such as wget -qO-)
# Source:
#   https://github.com/e36freak/templates/blob/master/options
unset options
# while the number of arguments is greater than 0
while ((${#}))
do
  case "${1}" in
    # if option is of type -ab
    -[!-]?*)
      # loop over each character starting with the second
      for ((i=1; i<${#1}; i++))
      do
        # extract 1 character from position 'i'
        c="${1:i:1}"
        # add current char to options
        options+=("-${c}")
      done
      ;;
    # if option is of type --foo=bar, split on first '='
    --?*=*)
      options+=("${1%%=*}" "${1#*=}")
      ;;
    # end of options, stop breaking them up
    --)
      options+=(--endopts)
      shift
      options+=("${@}")
      break
      ;;
    # otherwise, nothing special
    *)
      options+=("${1}")
      ;;
  esac

  shift
done
# set new positional parameters to altered options. Set default to blank.
set -- "${options[@]:-}"
unset options

_SUBCOMMAND=""
_SUBCOMMAND_ARGUMENTS=()
_USE_DEBUG=0
_YES=0
_RAW_TIME=0
_EPOCH_TIME=0

while ((${#}))
do
  __opt="${1}"
  shift
  case "${__opt}" in
    -h|--help)
      _SUBCOMMAND="help"
      ;;
    -e)
      _SUBCOMMAND="edit"
      ;;
    -v)
      _SUBCOMMAND="version"
      ;;
    --debug)
      _USE_DEBUG=1
      ;;
    -f)
      _YES=1
      ;;
    --iso)
      _RAW_TIME=1
      ;;
    --epoch)
      _EPOCH_TIME=1
      ;;
    *)
      # The first non-option argument is assumed to be the subcommand name.
      # All subsequent arguments are added to $_SUBCOMMAND_ARGUMENTS.
      if [[ -n "${_SUBCOMMAND}" ]]
      then
        _SUBCOMMAND_ARGUMENTS+=("${__opt}")
      else
        _SUBCOMMAND="${__opt}"
      fi
      ;;
  esac
done

###############################################################################
# Main
###############################################################################

_DEFINED_SUBCOMMANDS=()
_main() {
  if [[ -z "${_SUBCOMMAND}" ]]
  then
    _SUBCOMMAND="${NAG_DEFAULT}"
  fi

  for __name in $(declare -F)
  do
    local _function_name="${__name##* }"

    if ! { [[ -z "${_function_name:-}"                      ]] ||
           [[ "${_function_name}" =~ ^_(.*)                 ]] ||
           [[ "${_function_name}" == "bats_readlinkf"       ]] ||
           [[ "${_function_name}" == "describe"             ]] ||
           [[ "${_function_name}" == "shell_session_update" ]]
    }
    then
      _DEFINED_SUBCOMMANDS+=("${_function_name}")
    fi
  done

  # If our _SUBCOMMAND is defined, execute it. Otherwise, attempt to
  # execute _create_alarm. _create_alarm must check its own args, and
  # _exit_1 if inappropriate.
  if _contains "${_SUBCOMMAND}" "${_DEFINED_SUBCOMMANDS[@]:-}"
  then
    ${_SUBCOMMAND} "${_SUBCOMMAND_ARGUMENTS[@]:-}"
  else
    "${_FALLBACK_COMMAND_IF_NO_MATCH}" "${_SUBCOMMAND}" "${_SUBCOMMAND_ARGUMENTS[@]:-}"
  fi
}


###############################################################################
# Storage
###############################################################################

# Global array holding raw TSV alarm lines (one element per alarm).
_ALARMS=()

# Usage:
#   _play_sound [<tags>]
#
# Description:
#   Play the alarm sound if NAG_SOUND exists and not muted.
_play_sound() {
  local _tags="${1:-}"
  [[ -n "${NAG_SOUND}" ]] && [[ -f "${NAG_SOUND}" ]] || return 0
  _is_muted "${_tags}" && return 0

  if _command_exists pw-play; then
    pw-play "${NAG_SOUND}"
  elif _command_exists paplay; then
    paplay "${NAG_SOUND}"
  elif _command_exists aplay; then
    aplay "${NAG_SOUND}"
  fi
}

# Usage:
#   _ensure_nag_dir
#
# Description:
#   Create the nag directory if it doesn't already exist.
_ensure_nag_dir() {
  [[ -d "${NAG_DIR}" ]] || mkdir -p "${NAG_DIR}"
}

# Usage:
#   _acquire_lock
#
# Description:
#   Acquire an exclusive lock on ${_LOCKFILE} using flock. Exits with an
#   error if the lock cannot be obtained (another instance is running).
_acquire_lock() {
  _ensure_nag_dir
  exec {_LOCK_FD}>"${_LOCKFILE}"
  if ! flock -n "${_LOCK_FD}"
  then
    _exit_1 printf "Could not acquire lock: %s\\n" "${_LOCKFILE}"
  fi
}

# Usage:
#   _release_lock
#
# Description:
#   Release the exclusive lock previously acquired by _acquire_lock.
_release_lock() {
  if [[ -n "${_LOCK_FD:-}" ]]
  then
    exec {_LOCK_FD}>&-
  fi
}

# Usage:
#   _read_alarms
#
# Description:
#   Read alarms from _ALARMS_FILE into the global _ALARMS array. Each element
#   is one raw TSV line. If the file is missing or empty, _ALARMS is set to
#   an empty array.
_read_alarms() {
  _ALARMS=()
  if [[ -f "${_ALARMS_FILE}" ]] && [[ -s "${_ALARMS_FILE}" ]]
  then
    local _line
    while IFS= read -r _line || [[ -n "${_line}" ]]
    do
      [[ -n "${_line}" ]] && _ALARMS+=("${_line}")
    done < "${_ALARMS_FILE}" || true
  fi
}

# Usage:
#   _write_alarms
#
# Description:
#   Write the _ALARMS array atomically to _ALARMS_FILE. Writes to a temporary
#   file first, then moves it over the original. If _ALARMS is empty, an
#   empty file is written.
_write_alarms() {
  _ensure_nag_dir
  local _tmp
  _tmp="$(mktemp "${_ALARMS_FILE}.XXXXXX")"
  if (( ${#_ALARMS[@]} > 0 ))
  then
    printf "%s\n" "${_ALARMS[@]}" > "${_tmp}"
  else
    : > "${_tmp}"
  fi
  mv -f "${_tmp}" "${_ALARMS_FILE}"
}

# Usage:
#   _next_id
#
# Description:
#   Find the next available alarm ID (the first gap in the existing ID
#   sequence starting from 1). Prints the ID to stdout.
_next_id() {
  _read_alarms
  if (( ${#_ALARMS[@]} == 0 ))
  then
    printf "%s\n" "1"
    return
  fi

  local _ids=()
  local _line
  for _line in "${_ALARMS[@]}"
  do
    _ids+=("$(printf "%s" "${_line}" | cut -f1)")
  done

  # Sort numerically
  local _sorted
  _sorted="$(printf "%s\n" "${_ids[@]}" | sort -n)"

  local _expected=1
  local _id
  while IFS= read -r _id
  do
    if (( _id != _expected ))
    then
      printf "%s\n" "${_expected}"
      return
    fi
    _expected=$((_expected + 1))
  done <<< "${_sorted}"

  printf "%s\n" "${_expected}"
}

# Usage:
#   _get_alarm_field <line> <field-number>
#
# Description:
#   Extract a field from a TSV alarm line.
#   Fields: 1=id, 2=tags, 3=timestamp, 4=rule, 5=message.
#   For field 5 (message), returns everything after the 4th tab.
_get_alarm_field() {
  local _line="${1}"
  local _field="${2}"
  local _rest="${_line}"
  local _i
  for (( _i = 1; _i < _field; _i++ ))
  do
    _rest="${_rest#*$'\t'}"
  done
  if (( _field == 5 ))
  then
    printf "%s" "${_rest}"
  else
    printf "%s" "${_rest%%$'\t'*}"
  fi
}

# Usage:
#   _alarm_has_tag <tags_field> <tag>
#
# Description:
#   Check whether a comma-separated tags field contains a specific tag.
#
# Exit / Error Status:
#   0 (success, true)  If the tag is present.
#   1 (error,  false)  If the tag is absent or the field is empty.
_alarm_has_tag() {
  local _tags_field="${1}" _tag="${2}"
  [[ -n "${_tags_field}" ]] || return 1
  local IFS=","
  local _t
  for _t in ${_tags_field}
  do
    [[ "${_t}" == "${_tag}" ]] && return 0
  done
  return 1
}

# Usage:
#   _is_muted <tags_field>
#
# Description:
#   Check whether an alarm should be muted based on the mute file entries.
#   Entries: "*" (global mute), "tag" (mute that tag), "!tag" (unmute that tag).
#   Explicit unmute (!tag) beats global mute (*); tag mute beats default.
#
# Exit / Error Status:
#   0 (success, true)  If the alarm is muted.
#   1 (error,  false)  If the alarm is not muted.
_is_muted() {
  local _tags_field="${1:-}"
  [[ -f "${_MUTE_FILE}" ]] || return 1

  local -a _mute_entries=()
  local _entry
  while IFS= read -r _entry || [[ -n "${_entry}" ]]
  do
    [[ -n "${_entry}" ]] && _mute_entries+=("${_entry}")
  done < "${_MUTE_FILE}"

  (( ${#_mute_entries[@]} > 0 )) || return 1

  if [[ -n "${_tags_field}" ]]
  then
    local IFS=","
    local _tag
    for _tag in ${_tags_field}
    do
      local _e
      for _e in "${_mute_entries[@]}"
      do
        [[ "${_e}" == "!${_tag}" ]] && return 1
      done
    done
  fi

  if [[ -n "${_tags_field}" ]]
  then
    local IFS=","
    local _tag
    for _tag in ${_tags_field}
    do
      local _e
      for _e in "${_mute_entries[@]}"
      do
        [[ "${_e}" == "${_tag}" ]] && return 0
      done
    done
  fi

  local _e
  for _e in "${_mute_entries[@]}"
  do
    [[ "${_e}" == "*" ]] && return 0
  done

  return 1
}

# Usage:
#   _is_snoozed <id>
#
# Description:
#   Check whether an alarm ID is currently snoozed. If the entry has an
#   expiry timestamp, it is only considered snoozed when the expiry is
#   still in the future.
#
# Exit / Error Status:
#   0 (success, true)  If the alarm is snoozed.
#   1 (error,  false)  If the alarm is not snoozed.
_is_snoozed() {
  local _id="${1}"
  [[ -f "${_SNOOZED_FILE}" ]] || return 1

  local _now _entry
  _now="$(date +%s)"

  while IFS= read -r _entry || [[ -n "${_entry}" ]]
  do
    [[ -n "${_entry}" ]] || continue
    local _entry_key="${_entry%%$'\t'*}"

    [[ "${_entry_key}" == "${_id}" ]] || continue

    # Check expiry if present.
    if [[ "${_entry}" == *$'\t'* ]]
    then
      local _expiry="${_entry#*$'\t'}"
      (( _expiry > _now )) && return 0
    else
      return 0
    fi
  done < "${_SNOOZED_FILE}"

  return 1
}

# Usage:
#   _sweep_expired_snoozes
#
# Description:
#   Remove stale snooze entries: expired (past expiry timestamp) and
#   orphaned (alarm ID no longer exists in _ALARMS). Expects _ALARMS
#   to be populated via _read_alarms before calling.
_sweep_expired_snoozes() {
  [[ -f "${_SNOOZED_FILE}" ]] || return 0

  local _now
  _now="$(date +%s)"
  local -a _keep=()
  local _entry

  while IFS= read -r _entry || [[ -n "${_entry}" ]]
  do
    [[ -n "${_entry}" ]] || continue

    local _entry_id="${_entry%%$'\t'*}"

    # Drop expired entries.
    if [[ "${_entry}" == *$'\t'* ]]
    then
      local _expiry="${_entry#*$'\t'}"
      (( _expiry <= _now )) && continue
    fi

    # Drop orphaned entries (alarm no longer exists).
    local _exists=0 _line
    for _line in "${_ALARMS[@]:-}"
    do
      [[ -n "${_line}" ]] || continue
      [[ "${_line%%$'\t'*}" == "${_entry_id}" ]] && _exists=1 && break
    done
    (( _exists )) || continue

    _keep+=("${_entry}")
  done < "${_SNOOZED_FILE}"

  if (( ${#_keep[@]} == 0 ))
  then
    rm -f "${_SNOOZED_FILE}"
  else
    printf "%s\n" "${_keep[@]}" > "${_SNOOZED_FILE}.tmp"
    mv -f "${_SNOOZED_FILE}.tmp" "${_SNOOZED_FILE}"
  fi
}

# Usage:
#   _get_snooze_display <id>
#
# Description:
#   Set REPLY to a display string for the given alarm ID's snooze status.
#   Returns " (snoozed)" for indefinite snoozes, " (snoozed until <date>)"
#   for timed snoozes, or "" if the alarm is not snoozed.
_get_snooze_display() {
  local _id="${1}"
  REPLY=""
  [[ -f "${_SNOOZED_FILE}" ]] || return 0

  local _now _entry
  _now="$(date +%s)"

  while IFS= read -r _entry || [[ -n "${_entry}" ]]
  do
    [[ -n "${_entry}" ]] || continue
    local _entry_key="${_entry%%$'\t'*}"

    [[ "${_entry_key}" == "${_id}" ]] || continue

    if [[ "${_entry}" == *$'\t'* ]]
    then
      local _expiry="${_entry#*$'\t'}"
      if (( _expiry > _now ))
      then
        _format_time "${_expiry}"
        REPLY=" (snoozed until ${REPLY})"
      fi
    else
      REPLY=" (snoozed)"
    fi
    return 0
  done < "${_SNOOZED_FILE}"
}

# Usage:
#   _remove_snoozed_entry <key>
#
# Description:
#   Remove the entry whose key matches <key> from the snoozed file.
#   Returns 1 if no matching entry was found or the file does not exist.
_remove_snoozed_entry() {
  local _key="${1}"
  [[ -f "${_SNOOZED_FILE}" ]] || return 1

  local -a _keep=()
  local _removed=0 _entry
  while IFS= read -r _entry || [[ -n "${_entry}" ]]
  do
    [[ -n "${_entry}" ]] || continue
    local _entry_key="${_entry%%$'\t'*}"
    if [[ "${_entry_key}" == "${_key}" ]]
    then
      _removed=1
    else
      _keep+=("${_entry}")
    fi
  done < "${_SNOOZED_FILE}"

  (( _removed )) || return 1

  if (( ${#_keep[@]} == 0 ))
  then
    rm -f "${_SNOOZED_FILE}"
  else
    printf "%s\n" "${_keep[@]}" > "${_SNOOZED_FILE}.tmp"
    mv -f "${_SNOOZED_FILE}.tmp" "${_SNOOZED_FILE}"
  fi
  return 0
}

# Usage:
#   _cleanup_alarm_metadata <id>
#
# Description:
#   Remove all metadata for an alarm ID (snoozed file entries).
#   Called when an alarm is permanently removed.
_cleanup_alarm_metadata() {
  _remove_snoozed_entry "${1}" 2>/dev/null || true
}

# Usage:
#   _tag_list <tag>
#
# Description:
#   List all alarms that carry the given tag, sorted by timestamp.
_tag_list() {
  local _filter_tag="${1}"

  if [[ ! -f "${_ALARMS_FILE}" ]] || [[ ! -s "${_ALARMS_FILE}" ]]
  then
    printf "No alarms tagged [%s].\\n" "${_filter_tag}"
    return 0
  fi

  _read_alarms

  local -a _sorted
  IFS=$'\n' _sorted=($(printf "%s\n" "${_ALARMS[@]}" | sort -t$'\t' -k3 -n))
  IFS=$'\n\t'

  local _today _today_epoch
  _today="$(date +%Y-%m-%d)"
  _today_epoch="$(date -d "${_today}" +%s)"

  local _matched=0
  local _line
  for _line in "${_sorted[@]}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message

    _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}"
    _tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
    _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
    _rule="${_rest%%$'\t'*}"
    _message="${_rest#*$'\t'}"

    _alarm_has_tag "${_tags}" "${_filter_tag}" || continue
    _matched=1

    local _human_time _rule_display _tag_display
    _format_time "${_timestamp}" "${_today_epoch}"
    _human_time="${REPLY}"

    if [[ -n "${_rule}" ]]
    then
      _rule_display=" (${_rule//,/, })"
    else
      _rule_display=""
    fi

    _tag_display=" [${_tags//,/, }]"

    local _snooze_display
    _get_snooze_display "${_id}"
    _snooze_display="${REPLY}"

    printf "[%s]%s %s%s%s — %s\\n" "${_id}" "${_tag_display}" "${_human_time}" "${_rule_display}" "${_snooze_display}" "${_message}"
  done

  if (( ! _matched ))
  then
    printf "No alarms tagged [%s].\\n" "${_filter_tag}"
  fi
}

# Usage:
#   _format_time <timestamp> [today_epoch]
#
# Description:
#   Format a unix timestamp for display. Sets REPLY to the result.
#   Time: dots for minutes (3.30pm), omit .00 (3pm).
#   Date: Today / Tomorrow / day-of-week within 6 days /
#         Next <day> within 13 days / Mon Dec 25 beyond that.
#   Optional today_epoch arg skips recomputing the current date.
_format_time() {
  local _timestamp="${1}"

  if (( _EPOCH_TIME ))
  then
    REPLY="${_timestamp}"
    return
  fi

  if (( _RAW_TIME ))
  then
    REPLY="$(date -d "@${_timestamp}" "+%Y-%m-%d %H:%M:%S")"
    return
  fi

  # Single date call to get all needed components.
  local _date_parts
  _date_parts="$(date -d "@${_timestamp}" "+%-I|%M|%p|%Y-%m-%d|%A|%a %b %-d")"

  local _saved_ifs="${IFS}"
  IFS='|'
  local -a _parts
  read -r -a _parts <<< "${_date_parts}"
  IFS="${_saved_ifs}"

  local _hour="${_parts[0]}"
  local _minute="${_parts[1]}"
  local _ampm="${_parts[2],,}"
  local _alarm_date="${_parts[3]}"
  local _day_name="${_parts[4]}"
  local _short_date="${_parts[5]}"

  local _alarm_midnight
  _alarm_midnight="$(date -d "${_alarm_date}" "+%s")"

  # Format time: drop .00, use dots for minutes.
  local _time_fmt
  if [[ "${_minute}" == "00" ]]
  then
    _time_fmt="${_hour}${_ampm}"
  else
    _time_fmt="${_hour}.${_minute}${_ampm}"
  fi

  # Format date prefix (midnight-to-midnight comparison).
  local _today_epoch _days_away
  if [[ -n "${2:-}" ]]
  then
    _today_epoch="${2}"
  else
    local _today
    _today="$(date +%Y-%m-%d)"
    _today_epoch="$(date -d "${_today}" +%s)"
  fi
  _days_away=$(( (_alarm_midnight - _today_epoch) / 86400 ))

  local _date_prefix
  if (( _days_away == 0 ))
  then
    _date_prefix="Today"
  elif (( _days_away == 1 ))
  then
    _date_prefix="Tomorrow"
  elif (( _days_away <= 6 ))
  then
    _date_prefix="${_day_name}"
  elif (( _days_away <= 13 ))
  then
    _date_prefix="Next ${_day_name}"
  else
    local _alarm_year
    _alarm_year="$(date -d "${_alarm_date}" +%Y)"
    local _this_year
    _this_year="$(date +%Y)"
    if [[ "${_alarm_year}" != "${_this_year}" ]]
    then
      _date_prefix="${_short_date} ${_alarm_year}"
    else
      _date_prefix="${_short_date}"
    fi
  fi

  REPLY="${_date_prefix}, ${_time_fmt}"
}

# Usage:
#   _normalise_rule <rule>
#
# Description:
#   Map a rule alias to its canonical short form. Prints the canonical
#   form to stdout. Input is expected to be lowercase.
_normalise_rule() {
  case "${1}" in
    h|hr|hour|hours|hourly)            printf "hour" ;;
    d|day|days|daily)                  printf "day" ;;
    week|weekly)                       printf "week" ;;
    weekday|weekdays)                  printf "weekday" ;;
    weekend|weekends)                  printf "weekend" ;;
    mon|monday|mondays)                printf "mon" ;;
    tue|tuesday|tuesdays)              printf "tue" ;;
    wed|wednesday|wednesdays)          printf "wed" ;;
    thurs|thursday|thursdays)          printf "thu" ;;
    fri|friday|fridays)                printf "fri" ;;
    sat|saturday|saturdays)            printf "sat" ;;
    sun|sunday|sundays)                printf "sun" ;;
    month|months|monthly)              printf "month" ;;
    year|years|yearly)                 printf "year" ;;
    *)                                 return 1 ;;
  esac
}

# Usage:
#   _validate_and_normalise_rules <rules_string>
#
# Description:
#   Validate and normalise a comma-separated list of repeat rules.
#   Input is case-insensitive. Prints the normalised comma-separated
#   rules to stdout. Exits with error if any rule is invalid.
_validate_and_normalise_rules() {
  local _rules_str="${1:-}"
  [[ -n "${_rules_str}" ]] || _exit_1 printf "No rules specified.\\n"

  # Lowercase the input.
  _rules_str="$(printf "%s" "${_rules_str}" | tr '[:upper:]' '[:lower:]')"

  local _normalised=()
  local IFS=","
  local _rule
  for _rule in ${_rules_str}
  do
    local _canon
    _canon="$(_normalise_rule "${_rule}")" ||
      _exit_1 printf "Invalid rule: %s.\\n" "${_rule}"
    _normalised+=("${_canon}")
  done
  IFS=$'\n\t'

  # Join with commas.
  local _result="${_normalised[0]}"
  local _i
  for (( _i=1; _i<${#_normalised[@]}; _i++ ))
  do
    _result="${_result},${_normalised[${_i}]}"
  done

  printf "%s" "${_result}"
}

# Usage:
#   _next_occurrence <rules> <timestamp>
#
# Description:
#   Compute the next occurrence for a repeating alarm. For comma-separated
#   rules, returns the earliest next occurrence across all rules.
_next_occurrence() {
  local _rules_str="${1}"
  local _timestamp="${2}"
  local _earliest=""

  local IFS=","
  local _rule
  for _rule in ${_rules_str}
  do
    local _next
    _next="$(_next_for_rule "${_rule}" "${_timestamp}")"
    if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
    then
      _earliest="${_next}"
    fi
  done

  printf "%s" "${_earliest}"
}

# Usage:
#   _next_for_rule <rule> <timestamp>
#
# Description:
#   Compute the next occurrence for a single repeat rule.
_next_for_rule() {
  local _rule="${1}"
  local _timestamp="${2}"
  local _time_of_day
  _time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"

  case "${_rule}" in
    hour)    printf "%s" "$((_timestamp + 3600))" ;;
    day)     date -d "$(date -d "@${_timestamp}" +%Y-%m-%d) + 1 day ${_time_of_day}" +%s ;;
    week)    _next_matching_day "${_timestamp}" "${_time_of_day}" "$(date -d "@${_timestamp}" +%u)" ;;
    weekday) _next_matching_day "${_timestamp}" "${_time_of_day}" "1 2 3 4 5" ;;
    weekend) _next_matching_day "${_timestamp}" "${_time_of_day}" "6 7" ;;
    mon)     _next_matching_day "${_timestamp}" "${_time_of_day}" "1" ;;
    tue)     _next_matching_day "${_timestamp}" "${_time_of_day}" "2" ;;
    wed)     _next_matching_day "${_timestamp}" "${_time_of_day}" "3" ;;
    thu)     _next_matching_day "${_timestamp}" "${_time_of_day}" "4" ;;
    fri)     _next_matching_day "${_timestamp}" "${_time_of_day}" "5" ;;
    sat)     _next_matching_day "${_timestamp}" "${_time_of_day}" "6" ;;
    sun)     _next_matching_day "${_timestamp}" "${_time_of_day}" "7" ;;
    month)   _next_month "${_timestamp}" "${_time_of_day}" ;;
    year)    _next_year "${_timestamp}" "${_time_of_day}" ;;
  esac
}

# Usage:
#   _next_matching_day <timestamp> <time_of_day> <target_days>
#
# Description:
#   Walk forward from tomorrow until a day-of-week matches one of the
#   target days. Days are space-separated, 1=Mon 7=Sun (date +%u format).
_next_matching_day() {
  local _timestamp="${1}"
  local _time_of_day="${2}"
  local _targets="${3}"
  local _base_date
  _base_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"

  local _i
  for _i in 1 2 3 4 5 6 7
  do
    local _candidate_ts _candidate_dow
    _candidate_ts="$(date -d "${_base_date} + ${_i} day ${_time_of_day}" +%s)"
    _candidate_dow="$(date -d "${_base_date} + ${_i} day" +%u)"

    if [[ " ${_targets} " == *" ${_candidate_dow} "* ]]
    then
      printf "%s" "${_candidate_ts}"
      return 0
    fi
  done
}

# Usage:
#   _next_month <timestamp> <time_of_day>
#
# Description:
#   Same day-of-month next month, same time. Clamps to last day of
#   month if the day doesn't exist (e.g. 31st in a 30-day month).
_next_month() {
  local _timestamp="${1}"
  local _time_of_day="${2}"
  local _day _month _year
  _day="$(date -d "@${_timestamp}" +%-d)"
  _month="$(date -d "@${_timestamp}" +%-m)"
  _year="$(date -d "@${_timestamp}" +%Y)"

  _month=$((_month + 1))
  if (( _month > 12 ))
  then
    _month=1
    _year=$((_year + 1))
  fi

  local _last_day
  _last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"

  if (( _day > _last_day ))
  then
    _day="${_last_day}"
  fi

  date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}

# Usage:
#   _next_year <timestamp> <time_of_day>
#
# Description:
#   Same month and day next year, same time. Feb 29 in a non-leap year
#   clamps to Feb 28.
_next_year() {
  local _timestamp="${1}"
  local _time_of_day="${2}"
  local _day _month _year
  _day="$(date -d "@${_timestamp}" +%-d)"
  _month="$(date -d "@${_timestamp}" +%-m)"
  _year="$(date -d "@${_timestamp}" +%Y)"

  _year=$((_year + 1))

  if ! date -d "$(printf "%04d-%02d-%02d" "${_year}" "${_month}" "${_day}")" &>/dev/null
  then
    local _last_day
    _last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"
    _day="${_last_day}"
  fi

  date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}

# Usage:
#   _first_occurrence <rules> <timestamp>
#
# Description:
#   Compute the first valid occurrence for a repeating alarm. If the
#   given timestamp already falls on a matching day, use it. Otherwise
#   find the next matching day at the same time-of-day.
_first_occurrence() {
  local _rules_str="${1}"
  local _timestamp="${2}"

  if _timestamp_matches_rule "${_rules_str}" "${_timestamp}"
  then
    printf "%s" "${_timestamp}"
  else
    # Use a timestamp from yesterday so _next_occurrence walks from today.
    local _yesterday=$((_timestamp - 86400))
    local _time_of_day
    _time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"

    local _earliest=""
    local IFS=","
    local _rule
    for _rule in ${_rules_str}
    do
      local _next
      _next="$(_next_for_rule "${_rule}" "${_yesterday}")"
      if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
      then
        _earliest="${_next}"
      fi
    done

    printf "%s" "${_earliest}"
  fi
}

# Usage:
#   _unskip_timestamp <timestamp> <rules>
#
# Description:
#   Compute the next natural occurrence of a repeating alarm from now.
#   Handles each rule type appropriately: day-of-week rules use today's
#   date at alarm's time, month preserves day-of-month, year preserves
#   month and day. For comma-separated rules, returns the earliest.
_unskip_timestamp() {
  local _timestamp="${1}" _rules="${2}"
  local _now
  _now="$(date +%s)"

  local _time_of_day _alarm_day _alarm_month
  _time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
  _alarm_day="$(date -d "@${_timestamp}" +%-d)"
  _alarm_month="$(date -d "@${_timestamp}" +%-m)"

  local _earliest=""
  local IFS=","
  local _rule
  for _rule in ${_rules}
  do
    local _candidate=""
    case "${_rule}" in
      hour)
        _candidate="$((_now - (_now % 3600) + $(date -d "@${_timestamp}" +%s) % 3600))"
        if (( _candidate <= _now )); then
          _candidate=$((_candidate + 3600))
        fi
        ;;
      day)
        _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)"
        if (( _candidate <= _now )); then
          _candidate="$(date -d "$(date +%Y-%m-%d) + 1 day ${_time_of_day}" +%s)"
        fi
        ;;
      month)
        local _year_now _month_now
        _year_now="$(date +%Y)"
        _month_now="$(date +%-m)"
        _candidate="$(date -d "$(printf "%04d-%02d-%02d %s" "${_year_now}" "${_month_now}" "${_alarm_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _candidate=0
        if (( _candidate <= _now )); then
          if (( _candidate == 0 )); then
            _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)"
          fi
          _candidate="$(_next_month "${_candidate}" "${_time_of_day}")"
        fi
        ;;
      year)
        local _year_now
        _year_now="$(date +%Y)"
        _candidate="$(date -d "$(printf "%04d-%02d-%02d %s" "${_year_now}" "${_alarm_month}" "${_alarm_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _candidate=0
        if (( _candidate <= _now )); then
          if (( _candidate == 0 )); then
            _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)"
          fi
          _candidate="$(_next_year "${_candidate}" "${_time_of_day}")"
        fi
        ;;
      *)
        # Day-of-week rules (weekday, weekend, mon-sun, week).
        local _today_at_alarm_time
        _today_at_alarm_time="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)"
        _candidate="$(_first_occurrence "${_rule}" "${_today_at_alarm_time}")"
        if (( _candidate <= _now )); then
          _candidate="$(_next_occurrence "${_rule}" "${_candidate}")"
        fi
        ;;
    esac

    if [[ -n "${_candidate}" ]] && (( _candidate > 0 )); then
      if [[ -z "${_earliest}" ]] || (( _candidate < _earliest )); then
        _earliest="${_candidate}"
      fi
    fi
  done

  printf "%s" "${_earliest}"
}

# Usage:
#   _timestamp_matches_rule <rules> <timestamp>
#
# Description:
#   Check if a timestamp falls on a day that matches any of the given rules.
_timestamp_matches_rule() {
  local _rules_str="${1}"
  local _timestamp="${2}"
  local _dow
  _dow="$(date -d "@${_timestamp}" +%u)"  # 1=Mon 7=Sun

  local IFS=","
  local _rule
  for _rule in ${_rules_str}
  do
    case "${_rule}" in
      hour|day|week|month|year) return 0 ;;
      weekday) [[ " 1 2 3 4 5 " == *" ${_dow} "* ]] && return 0 ;;
      weekend) [[ " 6 7 " == *" ${_dow} "* ]] && return 0 ;;
      mon) [[ "${_dow}" == "1" ]] && return 0 ;;
      tue) [[ "${_dow}" == "2" ]] && return 0 ;;
      wed) [[ "${_dow}" == "3" ]] && return 0 ;;
      thu) [[ "${_dow}" == "4" ]] && return 0 ;;
      fri) [[ "${_dow}" == "5" ]] && return 0 ;;
      sat) [[ "${_dow}" == "6" ]] && return 0 ;;
      sun) [[ "${_dow}" == "7" ]] && return 0 ;;
    esac
  done
  return 1
}

# Usage:
#   _parse_time <time-string>
#
# Description:
#   Parse a human-readable time string via `date -d` and print a unix
#   timestamp. If the result is in the past, roll forward to the next
#   occurrence. Rejects explicitly backward terms like "yesterday"/"ago".
_parse_time() {
  local _time_str="${1:-}"
  [[ -n "${_time_str}" ]] || _exit_1 printf "No time specified.\\n"

  local _timestamp
  _timestamp="$(date -d "${_time_str}" +%s 2>/dev/null)" ||
    _exit_1 printf "Invalid time: %s. See 'date -d' for accepted formats.\\n" "${_time_str}"

  local _lower
  _lower="$(printf "%s" "${_time_str}" | tr '[:upper:]' '[:lower:]')"
  if [[ "${_lower}" == *"yesterday"* ]] || [[ "${_lower}" == *" ago"* ]] || [[ "${_lower}" == "last "* ]]
  then
    _exit_1 printf "Time is in the past: %s.\\n" "${_time_str}"
  fi

  local _now
  _now="$(date +%s)"

  if (( _timestamp <= _now ))
  then
    local _time_of_day _month _day
    _time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
    _month="$(date -d "@${_timestamp}" +%-m)"
    _day="$(date -d "@${_timestamp}" +%-d)"

    local _today_date _parsed_date
    _today_date="$(date +%Y-%m-%d)"
    _parsed_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"

    if [[ "${_parsed_date}" == "${_today_date}" ]]
    then
      _timestamp="$(date -d "tomorrow ${_time_of_day}" +%s)"
    else
      local _next_year
      _next_year="$(date +%Y)"
      local _this_year_ts
      _this_year_ts="$(date -d "$(printf "%04d-%02d-%02d %s" "${_next_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _this_year_ts=0
      if (( _this_year_ts > _now ))
      then
        _timestamp="${_this_year_ts}"
      else
        _next_year=$((_next_year + 1))
        _timestamp="$(date -d "$(printf "%04d-%02d-%02d %s" "${_next_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s)"
      fi
    fi
  fi

  printf "%s" "${_timestamp}"
}

# Usage:
#   _prompt_timer
#
# Description:
#   Check whether a systemd user timer for `nag check` is active.
#   If not, prompt the user to install one (or install automatically
#   when -f is set). Falls back to cron if systemd is unavailable.
_prompt_timer() {
  # Already running?
  if systemctl --user is-active nag.timer &>/dev/null
  then
    return 0
  fi

  # Cron fallback: already installed?
  if crontab -l 2>/dev/null | grep -qF "${_ME} check"
  then
    return 0
  fi

  if ((_YES))
  then
    _install_timer
    return 0
  fi

  if _interactive_input
  then
    printf "A timer for '%s check' is needed to trigger alarms. Install one? [Y/n] " "${_ME}"
    local _reply
    read -r _reply
    case "${_reply}" in
      [nN]*)
        return 0
        ;;
      *)
        _install_timer
        ;;
    esac
  fi
}

# Usage:
#   _install_timer
#
# Description:
#   Install a systemd user timer that runs `nag check` every 15 seconds.
#   Falls back to cron if systemctl is unavailable.
_install_timer() {
  local _nag_path
  _nag_path="$(command -v nag 2>/dev/null || printf "%s" "$(cd "$(dirname "${0}")" && pwd)/nag")"

  if _command_exists systemctl
  then
    local _unit_dir="${HOME}/.config/systemd/user"
    mkdir -p "${_unit_dir}"

    cat > "${_unit_dir}/nag.service" <<UNIT
[Unit]
Description=nag alarm check

[Service]
Type=oneshot
ExecStart=${_nag_path} check
UNIT

    cat > "${_unit_dir}/nag.timer" <<UNIT
[Unit]
Description=Run nag check every 15 seconds

[Timer]
OnBootSec=15s
OnUnitActiveSec=15s
AccuracySec=1s

[Install]
WantedBy=timers.target
UNIT

    systemctl --user daemon-reload
    systemctl --user enable --now nag.timer
    printf "Systemd timer installed (every 15s).\\n"
  elif _command_exists crontab
  then
    (crontab -l 2>/dev/null; printf "* * * * * %s check\\n" "${_nag_path}") | crontab -
    printf "Cron entry added (every 60s).\\n"
  else
    _warn printf "Neither systemctl nor crontab found. Alarms will not trigger automatically.\\n"
  fi
}

###############################################################################
# Subcommands
###############################################################################

# help ########################################################################

describe "help" <<HEREDOC
Usage:
  ${_ME} help [<subcommand>]

Description:
  Display help information for ${_ME} or a specified subcommand.
HEREDOC
help() {
  if [[ -n "${1:-}" ]]
  then
    describe --get "${1}"
  else
    cat <<HEREDOC
Usage:
  ${_ME}                                     list all alarms
  ${_ME} <time> <message...>                 one-shot alarm
  ${_ME} every <rules> <time> <message...>   repeating alarm
  ${_ME} stop <all|id|tag>                   delete alarm(s)
  ${_ME} skip <all|id|tag>                   skip next occurrence(s)
  ${_ME} unskip <all|id|tag>                 reset to next occurrence
  ${_ME} tag <id> <tags...>                  add tags to an alarm
  ${_ME} tag <tag>                           list alarms with a tag
  ${_ME} untag <id> <tags...>                remove tags from an alarm
  ${_ME} snooze <all|id|tag> [<duration>]    snooze alarms
  ${_ME} unsnooze <all|id|tag>               unsnooze alarms
  ${_ME} check                               check and fire due alarms
  ${_ME} mute <all|tag>                      mute alarm sounds
  ${_ME} unmute <all|tag>                    unmute alarm sounds
  ${_ME} edit                                edit alarms file directly
  ${_ME} help [<subcommand>]                 show help
  ${_ME} version                             show version

Options:
  -e         Edit alarms file directly.
  -f         Skip all prompts.
  --iso      Show times as YYYY-MM-DD HH:MM:SS.
  --epoch    Show times as unix timestamps.
  -v         Show version.

Environment:
  NAG_DIR    Alarm storage directory (default: ~/.local/share/nag)
  NAG_CMD    Notification command (default: notify-send)
  NAG_SOUND  Sound file to play (default: freedesktop bell sound)
HEREDOC
  fi
}

# version #####################################################################

describe "version" <<HEREDOC
Usage:
  ${_ME} ( version | -v )

Description:
  Display the current program version.
HEREDOC
version() {
  printf "%s\\n" "${_VERSION}"
}

# list ########################################################################

describe "list" <<HEREDOC
Usage:
  ${_ME} list

Description:
  List all alarms. This is the default when no subcommand is given.
HEREDOC
list() {
  if [[ ! -f "${_ALARMS_FILE}" ]] || [[ ! -s "${_ALARMS_FILE}" ]]
  then
    printf "Nothing to nag about.\\n"
    return 0
  fi

  _read_alarms

  if (( ${#_ALARMS[@]} == 0 ))
  then
    printf "Nothing to nag about.\\n"
    return 0
  fi

  # Sort alarms by timestamp (field 3).
  local -a _sorted
  IFS=$'\n' _sorted=($(printf "%s\n" "${_ALARMS[@]}" | sort -t$'\t' -k3 -n))
  IFS=$'\n\t'

  local _today _today_epoch
  _today="$(date +%Y-%m-%d)"
  _today_epoch="$(date -d "${_today}" +%s)"

  local _line
  for _line in "${_sorted[@]}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message _human_time _rule_display _tag_display

    _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}"
    _tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
    _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
    _rule="${_rest%%$'\t'*}"
    _message="${_rest#*$'\t'}"

    _format_time "${_timestamp}" "${_today_epoch}"
    _human_time="${REPLY}"

    if [[ -n "${_rule}" ]]
    then
      _rule_display=" (${_rule//,/, })"
    else
      _rule_display=""
    fi

    if [[ -n "${_tags}" ]]
    then
      _tag_display=" [${_tags//,/, }]"
    else
      _tag_display=""
    fi

    local _snooze_display
    _get_snooze_display "${_id}"
    _snooze_display="${REPLY}"

    printf "[%s]%s %s%s%s — %s\\n" "${_id}" "${_tag_display}" "${_human_time}" "${_rule_display}" "${_snooze_display}" "${_message}"
  done
}

# stop ########################################################################

describe "stop" <<HEREDOC
Usage:
  ${_ME} stop <all|id|tag>

Description:
  Stop an alarm by ID, or stop all alarms with a tag or "all".
HEREDOC
stop() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s stop <all|id|tag>\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    _stop_all
    return
  fi

  if [[ ! "${_target}" =~ ^[0-9]+$ ]]
  then
    _stop_by_tag "${_target}"
    return
  fi

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _found=0
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id
    _id="${_line%%$'\t'*}"
    if [[ "${_id}" == "${_target}" ]]
    then
      _found=1
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target}"
  fi

  if (( ${#_new_alarms[@]} == 0 ))
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi
  _cleanup_alarm_metadata "${_target}"
  _release_lock

  printf "Stopped alarm %s.\\n" "${_target}"
}

_stop_by_tag() {
  local _tag="${1}"

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local -a _matched=()
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags
    _tags="$(_get_alarm_field "${_line}" 2)"
    if _alarm_has_tag "${_tags}" "${_tag}"
    then
      _matched+=("${_line}")
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ${#_matched[@]} == 0 ))
  then
    _release_lock
    _exit_1 printf "No alarms tagged [%s].\\n" "${_tag}"
  fi

  if ! _confirm_action "Stop" "${#_matched[@]}" "${_tag}"
  then
    _release_lock
    return 0
  fi

  if (( ${#_new_alarms[@]} == 0 ))
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi

  local _m
  for _m in "${_matched[@]}"
  do
    local _id _message
    _id="${_m%%$'\t'*}"
    _message="$(_get_alarm_field "${_m}" 5)"
    _cleanup_alarm_metadata "${_id}"
    printf "Stopped [%s] %s\\n" "${_id}" "${_message}"
  done
  _release_lock
}

_stop_all() {
  _acquire_lock
  _read_alarms

  if (( ${#_ALARMS[@]} == 0 ))
  then
    _release_lock
    _exit_1 printf "No alarms to stop.\\n"
  fi

  if ! _confirm_action "Stop" "${#_ALARMS[@]}"
  then
    _release_lock
    return 0
  fi

  local _line
  for _line in "${_ALARMS[@]}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _message
    _id="${_line%%$'\t'*}"
    _message="$(_get_alarm_field "${_line}" 5)"
    _cleanup_alarm_metadata "${_id}"
    printf "Stopped [%s] %s\\n" "${_id}" "${_message}"
  done

  _ALARMS=()
  : > "${_ALARMS_FILE}"
  _release_lock
}

# skip ########################################################################

describe "skip" <<HEREDOC
Usage:
  ${_ME} skip <all|id|tag>

Description:
  Skip the next occurrence of a repeating alarm (reschedule without firing).
  For one-shot alarms, this deletes them. With a tag or "all", applies to all
  matching alarms.
HEREDOC
skip() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s skip <all|id|tag>\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    _skip_all
    return
  fi

  if [[ ! "${_target}" =~ ^[0-9]+$ ]]
  then
    _skip_by_tag "${_target}"
    return
  fi

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _found=0
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if [[ "${_id}" == "${_target}" ]]
    then
      _found=1
      if [[ -n "${_rule}" ]]
      then
        local _next_ts _human_time
        _next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
        _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_next_ts}" "${_rule}" "${_message}")")
        _format_time "${_next_ts}"
        _human_time="${REPLY}"
        printf "Skipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
      else
        _cleanup_alarm_metadata "${_id}"
        printf "Stopped [%s] %s\\n" "${_id}" "${_message}"
      fi
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target}"
  fi

  if (( ${#_new_alarms[@]} == 0 ))
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi
  _release_lock
}

_skip_by_tag() {
  local _tag="${1}"

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _match_count=0
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags
    _tags="$(_get_alarm_field "${_line}" 2)"
    if _alarm_has_tag "${_tags}" "${_tag}"
    then
      _match_count=$((_match_count + 1))
    fi
  done

  if (( _match_count == 0 ))
  then
    _release_lock
    _exit_1 printf "No alarms tagged [%s].\\n" "${_tag}"
  fi

  if ! _confirm_action "Skip" "${_match_count}" "${_tag}"
  then
    _release_lock
    return 0
  fi

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if _alarm_has_tag "${_tags}" "${_tag}"
    then
      if [[ -n "${_rule}" ]]
      then
        local _next_ts _human_time
        _next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
        _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_next_ts}" "${_rule}" "${_message}")")
        _format_time "${_next_ts}"
        _human_time="${REPLY}"
        printf "Skipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
      else
        _cleanup_alarm_metadata "${_id}"
        printf "Stopped [%s] %s\\n" "${_id}" "${_message}"
      fi
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ${#_new_alarms[@]} == 0 ))
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi
  _release_lock
}

_skip_all() {
  _acquire_lock
  _read_alarms

  if (( ${#_ALARMS[@]} == 0 ))
  then
    _release_lock
    _exit_1 printf "No alarms to skip.\\n"
  fi

  if ! _confirm_action "Skip" "${#_ALARMS[@]}"
  then
    _release_lock
    return 0
  fi

  local -a _new_alarms=()
  local _line

  for _line in "${_ALARMS[@]}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if [[ -n "${_rule}" ]]
    then
      local _next_ts _human_time
      _next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_next_ts}" "${_rule}" "${_message}")")
      _format_time "${_next_ts}"
      _human_time="${REPLY}"
      printf "Skipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
    else
      _cleanup_alarm_metadata "${_id}"
      printf "Stopped [%s] %s\\n" "${_id}" "${_message}"
    fi
  done

  if (( ${#_new_alarms[@]} == 0 ))
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi
  _release_lock
}

# unskip ######################################################################

describe "unskip" <<HEREDOC
Usage:
  ${_ME} unskip <all|id|tag>

Description:
  Reset a repeating alarm to its next natural occurrence from today.
  Reverses skips that pushed the alarm further into the future.
  Only applies to repeating alarms. With a tag, applies to all
  matching repeating alarms. With "all", resets every repeating alarm.
HEREDOC
unskip() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s unskip <all|id|tag>\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    _unskip_all
    return
  fi

  if [[ ! "${_target}" =~ ^[0-9]+$ ]]
  then
    _unskip_by_tag "${_target}"
    return
  fi

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _found=0 _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if [[ "${_id}" == "${_target}" ]]
    then
      _found=1
      if [[ -z "${_rule}" ]]
      then
        _release_lock
        _exit_1 printf "Alarm %s is a one-shot (no rule to unskip).\\n" "${_target}"
      fi

      local _new_ts
      _new_ts="$(_unskip_timestamp "${_timestamp}" "${_rule}")"

      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_new_ts}" "${_rule}" "${_message}")")

      local _human_time
      _format_time "${_new_ts}"
      _human_time="${REPLY}"
      printf "Unskipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target}"
  fi

  _ALARMS=("${_new_alarms[@]}")
  _write_alarms
  _release_lock
}

_unskip_by_tag() {
  local _tag="${1}"

  _acquire_lock
  _read_alarms

  local _match_count=0 _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags _rule
    _tags="$(_get_alarm_field "${_line}" 2)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    if _alarm_has_tag "${_tags}" "${_tag}" && [[ -n "${_rule}" ]]
    then
      _match_count=$((_match_count + 1))
    fi
  done

  if (( _match_count == 0 ))
  then
    _release_lock
    _exit_1 printf "No repeating alarms tagged [%s].\\n" "${_tag}"
  fi

  if ! _confirm_action "Unskip" "${_match_count}" "${_tag}"
  then
    _release_lock
    return 0
  fi

  local -a _new_alarms=()

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if _alarm_has_tag "${_tags}" "${_tag}" && [[ -n "${_rule}" ]]
    then
      local _new_ts
      _new_ts="$(_unskip_timestamp "${_timestamp}" "${_rule}")"

      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_new_ts}" "${_rule}" "${_message}")")
      local _human_time
      _format_time "${_new_ts}"
      _human_time="${REPLY}"
      printf "Unskipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
    else
      _new_alarms+=("${_line}")
    fi
  done

  _ALARMS=("${_new_alarms[@]}")
  _write_alarms
  _release_lock
}

_unskip_all() {
  _acquire_lock
  _read_alarms

  local _match_count=0 _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _rule
    _rule="$(_get_alarm_field "${_line}" 4)"
    [[ -n "${_rule}" ]] && _match_count=$((_match_count + 1))
  done

  if (( _match_count == 0 ))
  then
    _release_lock
    _exit_1 printf "No repeating alarms to unskip.\\n"
  fi

  if (( ! _YES ))
  then
    if _interactive_input
    then
      printf "Unskip all %s repeating alarm(s)? [y/N] " "${_match_count}"
      local _reply
      read -r _reply
      case "${_reply}" in
        [yY]*) ;;
        *)
          _release_lock
          return 0
          ;;
      esac
    else
      _release_lock
      printf "Unskip all %s repeating alarm(s)? Pass -f to confirm.\\n" "${_match_count}"
      return 0
    fi
  fi

  local -a _new_alarms=()

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if [[ -n "${_rule}" ]]
    then
      local _new_ts
      _new_ts="$(_unskip_timestamp "${_timestamp}" "${_rule}")"

      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_new_ts}" "${_rule}" "${_message}")")
      local _human_time
      _format_time "${_new_ts}"
      _human_time="${REPLY}"
      printf "Unskipped [%s] %s — next: %s\\n" "${_id}" "${_message}" "${_human_time}"
    else
      _new_alarms+=("${_line}")
    fi
  done

  _ALARMS=("${_new_alarms[@]}")
  _write_alarms
  _release_lock
}

# check #######################################################################

describe "check" <<HEREDOC
Usage:
  ${_ME} check

Description:
  Check for due alarms and fire them. Run automatically by a systemd
  timer (every 15s) or cron (every 60s). Repeating alarms are
  rescheduled. One-shot alarms are removed. Stale alarms older than
  15 minutes are silently dropped or rescheduled without firing.
HEREDOC
check() {
  [[ -f "${_ALARMS_FILE}" ]] || return 0

  _acquire_lock
  _read_alarms
  _sweep_expired_snoozes

  local -a _new_alarms=()
  local _now
  _now="$(date +%s)"
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue

    local _id _tags _timestamp _rule _message
    _id="$(_get_alarm_field "${_line}" 1)"
    _tags="$(_get_alarm_field "${_line}" 2)"
    _timestamp="$(_get_alarm_field "${_line}" 3)"
    _rule="$(_get_alarm_field "${_line}" 4)"
    _message="$(_get_alarm_field "${_line}" 5)"

    if _is_snoozed "${_id}"
    then
      _new_alarms+=("${_line}")
      continue
    fi

    if (( _timestamp <= _now ))
    then
      local _age=$(( _now - _timestamp ))
      local _should_fire=0

      if (( _age <= 900 ))
      then
        # Within 15 minutes, fire.
        _should_fire=1
      elif [[ "${_rule}" == "year" ]] && \
           [[ "$(date -d "@${_timestamp}" +%Y-%m-%d)" == "$(date +%Y-%m-%d)" ]]
      then
        # Yearly alarm, still the same day.
        _should_fire=1
      fi

      if (( _should_fire ))
      then
        ${NAG_CMD} "nag" "${_message}" || _warn printf "Failed to notify: %s\\n" "${_message}"
        _play_sound "${_tags}"
      fi

      if [[ -n "${_rule}" ]]
      then
        # Repeating: reschedule.
        local _next_ts
        _next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
        _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_tags}" "${_next_ts}" "${_rule}" "${_message}")")
      fi
      # One-shot: drop it (don't add to _new_alarms).
    else
      # Not expired — keep it.
      _new_alarms+=("${_line}")
    fi
  done

  if [[ "${#_new_alarms[@]}" -eq 0 ]]
  then
    _ALARMS=()
    : > "${_ALARMS_FILE}"
  else
    _ALARMS=("${_new_alarms[@]}")
    _write_alarms
  fi
  _release_lock
}

# at ##########################################################################

describe "at" <<HEREDOC
Usage:
  ${_ME} [at] <time> <message...>

Description:
  Create a one-shot alarm. The "at" is optional. If the first argument
  doesn't match any other subcommand of ${_ME}, it'll fallback to "at".

Examples:
  ${_ME} 3pm take a break
  ${_ME} at 3pm take a break
  ${_ME} "tomorrow 9am" dentist appointment
HEREDOC
at() {
  local _time_str="${1:-}"
  shift || true
  local _message
  IFS=' ' _message="${*:-}"
  IFS=$'\n\t'

  [[ -n "${_time_str}" ]] || _exit_1 printf "Usage: %s [at] <time> <message>\\n" "${_ME}"
  [[ -n "${_message}" ]]  || _exit_1 printf "No message specified.\\n"

  local _timestamp
  _timestamp="$(_parse_time "${_time_str}")"

  _acquire_lock
  local _id
  _id="$(_next_id)"

  # _next_id calls _read_alarms in a subshell, so _ALARMS isn't populated here.
  _read_alarms
  _ALARMS+=("$(printf "%s\t\t%s\t\t%s" "${_id}" "${_timestamp}" "${_message}")")
  _write_alarms
  _release_lock

  _prompt_timer

  local _human_time
  _format_time "${_timestamp}"
  _human_time="${REPLY}"
  printf "[%s] %s — %s\\n" "${_id}" "${_human_time}" "${_message}"
}

# every ########################################################################

describe "every" <<HEREDOC
Usage:
  ${_ME} every <rules> <time> <message...>

Description:
  Create a repeating alarm. Rules are comma-separated and case-insensitive.

Rules (and aliases):
  hour    (h, hourly)    monday    (mon, mondays)
  day     (d, daily)     tuesday   (tue, tuesdays)
  week    (weekly)       wednesday (wed, wednesdays)
  month   (monthly)      thursday  (thurs, thursdays)
  year    (yearly)       friday    (fri, fridays)
  weekday (weekdays)     saturday  (sat, saturdays)
  weekend (weekends)     sunday    (sun, sundays)

Examples:
  ${_ME} every weekday 9am standup meeting
  ${_ME} every tue,thu 3pm team sync
  ${_ME} every year "December 25" Christmas
HEREDOC
every() {
  local _rules_str="${1:-}"
  shift || true
  local _time_str="${1:-}"
  shift || true
  local _message
  IFS=' ' _message="${*:-}"
  IFS=$'\n\t'

  [[ -n "${_rules_str}" ]] || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
  [[ -n "${_time_str}" ]]  || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
  [[ -n "${_message}" ]]   || _exit_1 printf "No message specified.\\n"

  _rules_str="$(_validate_and_normalise_rules "${_rules_str}")"

  local _timestamp
  _timestamp="$(_parse_time "${_time_str}")"

  # Snap to the first occurrence that matches the rule.
  _timestamp="$(_first_occurrence "${_rules_str}" "${_timestamp}")"

  _acquire_lock
  local _id
  _id="$(_next_id)"

  # _next_id calls _read_alarms in a subshell, so _ALARMS isn't populated here.
  _read_alarms
  _ALARMS+=("$(printf "%s\t\t%s\t%s\t%s" "${_id}" "${_timestamp}" "${_rules_str}" "${_message}")")
  _write_alarms
  _release_lock

  _prompt_timer

  local _human_time
  _format_time "${_timestamp}"
  _human_time="${REPLY}"
  printf "[%s] %s (%s) — %s\\n" "${_id}" "${_human_time}" "${_rules_str//,/, }" "${_message}"
}

# mute ########################################################################

describe "mute" <<HEREDOC
Usage:
  ${_ME} mute <all|tag>

Description:
  Mute alarm sounds. Use 'all' to mute everything, or specify a tag.
HEREDOC
mute() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s mute <all|tag>\\n" "${_ME}"

  _ensure_nag_dir

  if [[ "${_target}" == "all" ]]
  then
    printf "*\n" > "${_MUTE_FILE}"
    printf "Muted all.\n"
  else
    if [[ -f "${_MUTE_FILE}" ]] && grep -Fxq '*' "${_MUTE_FILE}"
    then
      printf "Muted [%s].\n" "${_target}"
      return 0
    fi
    if ! [[ -f "${_MUTE_FILE}" ]] || ! grep -Fxq "${_target}" "${_MUTE_FILE}"
    then
      printf "%s\n" "${_target}" >> "${_MUTE_FILE}"
    fi
    printf "Muted [%s].\n" "${_target}"
  fi
}

# unmute ######################################################################

describe "unmute" <<HEREDOC
Usage:
  ${_ME} unmute <all|tag>

Description:
  Unmute alarm sounds. Use 'all' to unmute everything, or specify a tag.
HEREDOC
unmute() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s unmute <all|tag>\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    if [[ -f "${_MUTE_FILE}" ]]
    then
      rm -f "${_MUTE_FILE}"
      printf "Unmuted all.\n"
    else
      printf "Sound is not muted.\n"
    fi
  else
    _ensure_nag_dir
    if [[ -f "${_MUTE_FILE}" ]] && grep -Fxq "${_target}" "${_MUTE_FILE}"
    then
      { grep -Fxv "${_target}" "${_MUTE_FILE}" || true; } > "${_MUTE_FILE}.tmp"
      mv -f "${_MUTE_FILE}.tmp" "${_MUTE_FILE}"
    else
      printf "!%s\n" "${_target}" >> "${_MUTE_FILE}"
    fi
    printf "Unmuted [%s].\n" "${_target}"
  fi
}

# snooze ######################################################################

describe "snooze" <<HEREDOC
Usage:
  ${_ME} snooze <all|id|tag> [<duration>]

Description:
  Snooze alarms to prevent them from firing. Snoozed alarms stay in the
  alarm file but are skipped by check. With "all", snoozes every alarm.
  With an ID, snoozes that alarm. With a tag, snoozes all alarms with
  that tag. Optional duration sets an automatic expiry.

Examples:
  ${_ME} snooze all              snooze everything
  ${_ME} snooze 3               snooze alarm 3
  ${_ME} snooze work            snooze all [work] alarms
  ${_ME} snooze 3 "2 weeks"    snooze alarm 3 for 2 weeks
  ${_ME} snooze all "July 15"  snooze everything until July 15
HEREDOC
_snooze_by_tag() {
  local _tag="${1}" _duration_str="${2:-}"

  _acquire_lock
  _read_alarms

  local _match_count=0 _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags
    _tags="$(_get_alarm_field "${_line}" 2)"
    _alarm_has_tag "${_tags}" "${_tag}" && _match_count=$((_match_count + 1))
  done

  if (( _match_count == 0 ))
  then
    _release_lock
    _exit_1 printf "No alarms tagged [%s].\\n" "${_tag}"
  fi

  if ! _confirm_action "Snooze" "${_match_count}" "${_tag}"
  then
    _release_lock
    return 0
  fi

  _ensure_nag_dir

  local _until_ts="" _until_date=""
  if [[ -n "${_duration_str}" ]]
  then
    _until_ts="$(_parse_time "${_duration_str}")"
    _format_time "${_until_ts}"
    _until_date="${REPLY}"
  fi

  for _line in "${_ALARMS[@]}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags _id _message
    _tags="$(_get_alarm_field "${_line}" 2)"
    _alarm_has_tag "${_tags}" "${_tag}" || continue
    _id="${_line%%$'\t'*}"
    _message="$(_get_alarm_field "${_line}" 5)"
    _remove_snoozed_entry "${_id}" 2>/dev/null || true
    if [[ -n "${_until_ts}" ]]
    then
      printf "%s\t%s\\n" "${_id}" "${_until_ts}" >> "${_SNOOZED_FILE}"
      printf "Snoozed [%s] %s until %s.\\n" "${_id}" "${_message}" "${_until_date}"
    else
      printf "%s\\n" "${_id}" >> "${_SNOOZED_FILE}"
      printf "Snoozed [%s] %s.\\n" "${_id}" "${_message}"
    fi
  done

  _release_lock
}

snooze() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s snooze <all|id|tag> [<duration>]\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    local _duration_str="${2:-}"
    _acquire_lock
    _read_alarms

    if (( ${#_ALARMS[@]} == 0 ))
    then
      _release_lock
      _exit_1 printf "No alarms to snooze.\\n"
    fi

    _ensure_nag_dir

    local _until_ts="" _until_date=""
    if [[ -n "${_duration_str}" ]]
    then
      _until_ts="$(_parse_time "${_duration_str}")"
      _format_time "${_until_ts}"
      _until_date="${REPLY}"
    fi

    local _line
    for _line in "${_ALARMS[@]}"
    do
      [[ -n "${_line}" ]] || continue
      local _id _message
      _id="${_line%%$'\t'*}"
      _message="$(_get_alarm_field "${_line}" 5)"
      _remove_snoozed_entry "${_id}" 2>/dev/null || true
      if [[ -n "${_until_ts}" ]]
      then
        printf "%s\t%s\\n" "${_id}" "${_until_ts}" >> "${_SNOOZED_FILE}"
        printf "Snoozed [%s] %s until %s.\\n" "${_id}" "${_message}" "${_until_date}"
      else
        printf "%s\\n" "${_id}" >> "${_SNOOZED_FILE}"
        printf "Snoozed [%s] %s.\\n" "${_id}" "${_message}"
      fi
    done

    _release_lock
    return
  fi

  if [[ ! "${_target}" =~ ^[0-9]+$ ]]
  then
    _snooze_by_tag "${_target}" "${2:-}"
    return
  fi

  # Numeric: snooze by ID.
  _acquire_lock
  _read_alarms

  local _found=0 _message="" _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id
    _id="${_line%%$'\t'*}"
    if [[ "${_id}" == "${_target}" ]]
    then
      _found=1
      _message="$(_get_alarm_field "${_line}" 5)"
      break
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target}"
  fi

  local _duration_str="${2:-}"
  _ensure_nag_dir
  _remove_snoozed_entry "${_target}" 2>/dev/null || true
  if [[ -n "${_duration_str}" ]]
  then
    local _until_ts
    _until_ts="$(_parse_time "${_duration_str}")"
    printf "%s\t%s\\n" "${_target}" "${_until_ts}" >> "${_SNOOZED_FILE}"
    _release_lock
    _format_time "${_until_ts}"
    printf "Snoozed [%s] %s until %s.\\n" "${_target}" "${_message}" "${REPLY}"
  else
    printf "%s\\n" "${_target}" >> "${_SNOOZED_FILE}"
    _release_lock
    printf "Snoozed [%s] %s.\\n" "${_target}" "${_message}"
  fi
}

_unsnooze_by_tag() {
  local _tag="${1}"

  _acquire_lock
  _read_alarms

  # Find alarms with this tag whose IDs are in the snoozed file.
  local -a _snoozed_ids=()
  local _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _tags
    _tags="$(_get_alarm_field "${_line}" 2)"
    _alarm_has_tag "${_tags}" "${_tag}" || continue
    local _id="${_line%%$'\t'*}"
    # Check if this ID is in the snoozed file.
    if [[ -f "${_SNOOZED_FILE}" ]]
    then
      local _entry
      while IFS= read -r _entry || [[ -n "${_entry}" ]]
      do
        [[ -n "${_entry}" ]] || continue
        local _key="${_entry%%$'\t'*}"
        if [[ "${_key}" == "${_id}" ]]
        then
          _snoozed_ids+=("${_id}")
          break
        fi
      done < "${_SNOOZED_FILE}"
    fi
  done

  if (( ${#_snoozed_ids[@]} == 0 ))
  then
    _release_lock
    _exit_1 printf "[%s] alarms are not snoozed.\\n" "${_tag}"
  fi

  if ! _confirm_action "Unsnooze" "${#_snoozed_ids[@]}" "${_tag}"
  then
    _release_lock
    return 0
  fi

  local _id
  for _id in "${_snoozed_ids[@]}"
  do
    _remove_snoozed_entry "${_id}"
    local _message=""
    for _line in "${_ALARMS[@]:-}"
    do
      [[ -n "${_line}" ]] || continue
      local _aid="${_line%%$'\t'*}"
      if [[ "${_aid}" == "${_id}" ]]
      then
        _message="$(_get_alarm_field "${_line}" 5)"
        break
      fi
    done
    printf "Unsnoozed [%s] %s.\\n" "${_id}" "${_message}"
  done

  _release_lock
}

# unsnooze ###################################################################

describe "unsnooze" <<HEREDOC
Usage:
  ${_ME} unsnooze <all|id|tag>

Description:
  Unsnooze alarms. With "all", unsnoozes everything. With an ID,
  unsnoozes that alarm. With a tag, unsnoozes all alarms with that tag.
HEREDOC
unsnooze() {
  local _target="${1:-}"
  [[ -n "${_target}" ]] || _exit_1 printf "Usage: %s unsnooze <all|id|tag>\\n" "${_ME}"

  if [[ "${_target}" == "all" ]]
  then
    if [[ -f "${_SNOOZED_FILE}" ]]
    then
      _acquire_lock
      rm -f "${_SNOOZED_FILE}"
      _release_lock
      printf "Unsnoozed all.\\n"
    else
      printf "Nothing is snoozed.\\n"
    fi
    return
  fi

  if [[ ! "${_target}" =~ ^[0-9]+$ ]]
  then
    _unsnooze_by_tag "${_target}"
    return
  fi

  # Numeric: unsnooze by ID.
  _acquire_lock
  _read_alarms

  local _found=0 _message="" _line
  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id
    _id="${_line%%$'\t'*}"
    if [[ "${_id}" == "${_target}" ]]
    then
      _found=1
      _message="$(_get_alarm_field "${_line}" 5)"
      break
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target}"
  fi

  if ! _remove_snoozed_entry "${_target}"
  then
    _release_lock
    printf "Alarm %s is not snoozed.\\n" "${_target}"
    return
  fi

  _release_lock
  printf "Unsnoozed [%s] %s.\\n" "${_target}" "${_message}"
}

# edit ########################################################################

describe "edit" <<HEREDOC
Usage:
  ${_ME} ( edit | -e )

Description:
  Open the alarms file in \$EDITOR (falls back to \$VISUAL, then vi).
HEREDOC
edit() {
  _ensure_nag_dir
  [[ -f "${_ALARMS_FILE}" ]] || : > "${_ALARMS_FILE}"
  "${EDITOR:-${VISUAL:-vi}}" "${_ALARMS_FILE}"
}

# tag #########################################################################

describe "tag" <<HEREDOC
Usage:
  ${_ME} tag <id> <tags...>    add tags to an alarm
  ${_ME} tag <tag>             list alarms with a tag

Description:
  Add tags to an alarm by ID, or list all alarms matching a tag.
  Tags must not be pure integers.
HEREDOC
tag() {
  local _first="${1:-}"
  [[ -n "${_first}" ]] || _exit_1 printf "Usage: %s tag <id> <tags...> or %s tag <tag>\\n" "${_ME}" "${_ME}"

  if [[ ! "${_first}" =~ ^[0-9]+$ ]]
  then
    _tag_list "${_first}"
    return
  fi

  local _target_id="${_first}"
  shift
  [[ -n "${1:-}" ]] || _exit_1 printf "Usage: %s tag <id> <tags...>\\n" "${_ME}"

  local -a _requested_tags=("$@")
  local _tag
  for _tag in "${_requested_tags[@]}"
  do
    if [[ "${_tag}" =~ ^[0-9]+$ ]]
    then
      _exit_1 printf "Tag cannot be a number: %s\\n" "${_tag}"
    fi
    if [[ "${_tag}" == "all" ]]
    then
      _exit_1 printf "Tag name 'all' is reserved.\\n"
    fi
  done

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _found=0
  local _result_tags=""
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _existing_tags _timestamp _rule _message
    _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}"
    if [[ "${_id}" == "${_target_id}" ]]
    then
      _found=1
      _existing_tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
      _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
      _rule="${_rest%%$'\t'*}"
      _message="${_rest#*$'\t'}"

      local -a _tag_arr=()
      if [[ -n "${_existing_tags}" ]]
      then
        IFS=',' read -r -a _tag_arr <<< "${_existing_tags}"
      fi

      for _tag in "${_requested_tags[@]}"
      do
        local _already=0 _t
        for _t in "${_tag_arr[@]:-}"
        do
          [[ "${_t}" == "${_tag}" ]] && _already=1 && break
        done
        (( _already )) || _tag_arr+=("${_tag}")
      done

      _result_tags="$(_join "," "${_tag_arr[@]}")"
      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_result_tags}" "${_timestamp}" "${_rule}" "${_message}")")
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target_id}"
  fi

  _ALARMS=("${_new_alarms[@]}")
  _write_alarms
  _release_lock

  local _acted_on
  _acted_on="$(_join "," "${_requested_tags[@]}")"
  printf "Tagged [%s] %s.\\n" "${_target_id}" "${_acted_on//,/, }"
}

describe "untag" <<HEREDOC
Usage:
  ${_ME} untag <id> <tags...>

Description:
  Remove one or more tags from an alarm.
HEREDOC
untag() {
  local _target_id="${1:-}"
  [[ -n "${_target_id}" ]] || _exit_1 printf "Usage: %s untag <id> <tags...>\\n" "${_ME}"
  shift
  [[ -n "${1:-}" ]] || _exit_1 printf "Usage: %s untag <id> <tags...>\\n" "${_ME}"
  local -a _target_tags=("$@")

  _acquire_lock
  _read_alarms

  local -a _new_alarms=()
  local _found=0
  local _tags_found=0
  local _line

  for _line in "${_ALARMS[@]:-}"
  do
    [[ -n "${_line}" ]] || continue
    local _id _existing_tags _timestamp _rule _message
    _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}"
    if [[ "${_id}" == "${_target_id}" ]]
    then
      _found=1
      _existing_tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
      _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}"
      _rule="${_rest%%$'\t'*}"
      _message="${_rest#*$'\t'}"

      local -a _tag_arr=()
      local -a _new_tag_arr=()
      if [[ -n "${_existing_tags}" ]]
      then
        IFS=',' read -r -a _tag_arr <<< "${_existing_tags}"
      fi

      local _t
      for _t in "${_tag_arr[@]:-}"
      do
        local _removing=0 _rt
        for _rt in "${_target_tags[@]}"
        do
          [[ "${_t}" == "${_rt}" ]] && _removing=1 && _tags_found=$((_tags_found + 1)) && break
        done
        (( _removing )) || _new_tag_arr+=("${_t}")
      done

      local _new_tags=""
      if (( ${#_new_tag_arr[@]} > 0 ))
      then
        _new_tags="$(_join "," "${_new_tag_arr[@]}")"
      fi
      _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_new_tags}" "${_timestamp}" "${_rule}" "${_message}")")
    else
      _new_alarms+=("${_line}")
    fi
  done

  if (( ! _found ))
  then
    _release_lock
    _exit_1 printf "No alarm with ID %s.\\n" "${_target_id}"
  fi

  if (( ! _tags_found ))
  then
    _release_lock
    _exit_1 printf "Alarm %s does not have those tags.\\n" "${_target_id}"
  fi

  _ALARMS=("${_new_alarms[@]}")
  _write_alarms
  _release_lock

  local _acted_on
  _acted_on="$(_join "," "${_target_tags[@]}")"
  printf "Untagged [%s] %s.\\n" "${_target_id}" "${_acted_on//,/, }"
}

# _main must be called after everything has been defined.
_main
