;; Copyright (c) 2025 Sean Cole <scole@NetBSD.org>
;; All rights reserved.
;;
;; Redistribution and use in source and binary forms, with or without
;; modification, are permitted provided that the following conditions
;; are met:
;;
;; 1. Redistributions of source code must retain the above copyright
;;    notice, this list of conditions and the following disclaimer.
;; 2. Redistributions in binary form must reproduce the above copyright
;;    notice, this list of conditions and the following disclaimer in the
;;    documentation and/or other materials provided with the distribution.
;;
;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
;; "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
;; TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
;; PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
;; BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
;; CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
;; SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
;; CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
;; ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
;; POSSIBILITY OF SUCH DAMAGE.
;;
;; see README.txt for information on how to set up and use
;;
;; TODO
;; - info file for this module
;; - byte compile module
;; - play audio for individual vowels
;; - say and look up consonants, but don't use for spell checking (ก or ก:)
;; - in 'pthai-spell-suggest, suggestions with word frequencies, cap number of choices
;; - allow spaces in words for spelling choices (?)
;; - handle unsplit words with word completion
;; - vote count splitting, 3 split methods "จ๋าน" 1 "จ๋า น" take first(?)
;; - handle zero-width spaces et al. (ZWSP) มาฟัง​เคล็ดลับ​เตรียมตัว​สัมภาษณ์​อย่างไร​ให้​ได้งาน
;; - fix thai-break-words when split is obviously wrong, ครูเนี่ย => "ครูเ นี่ย", ไปใหน => "ไปใ หน"
;; - handle split better when last part of string not in dict/unknown, e.g.,
;;   when รองเท้าสตั๊ด & สตั๊ด not in dictionary, whole word unknown with pthai-twt-split,
;;   same issue with ธนาคารไทยทนุ
;; - add *sounds-like function to lookup similar sounding words
;; - accomodate heteronyms (แหม แหง สระ โบราณสถาน...)
;; - handle saying of words with ๆ better, e.g. ทั้งๆที่
;; - issues with sox under cygwin like utf-8 filenames and audio distorting(?)
;; - use single elisp splitter that can handle long strings & misspellings better
;; - add menu in pthai-mode (?)
;; - look up word definitions on the fly (?)
;; - 'fill-paragraph to split across strings
;; - refresh modeline col/row positions when saying words and/or progress reporting
;; - bounds messed up if buffer modified while 'pthai-bounds-of-thaistring-at-point
;; - consolidate function names, default get word at point, active region, prompt user
;;   use standard function to get region or buffer
;; - pthai-bounds-region: unify word boundary detection for:
;;   pthai-split-region pthai-say-region-helper pthai-spell-region*
;;   pthai-count-words pthai-practice-region
;;   pthai-unknown-bounds/pthai-unknown-words
;;
(require 'cus-edit)   ;; custom* customize*
(require 'easy-mmode) ;; define-minor-mode
(require 'json)       ;; json-*
(require 'mule-util)  ;; nested alist functions
(require 'pulse)      ;; pulse* functions
(require 'seq)        ;; seq-* functions
(require 'simple)     ;; count-* functions
(require 'sort)       ;; sort-lines
(require 'subr-x)     ;; string-trim* functions
(require 'thai-word)  ;; thai-word-table
(require 'thingatpt)  ;; thing-at-point*
(require 'url)        ;; url-* functions

(unless (boundp 'pthai-bootstrap)
  (defconst pthai-bootstrap nil "nil unless building for pkgsrc"))

(defvar pthai-default-directory
  (expand-file-name "pthai" user-emacs-directory) "default pthai directory")

(defvar pthai-dictionary (make-hash-table :test 'equal)
   "hash of thai dictionary words.  each word has a dictionary entry, \n\
the form of a dictionary entry is:\n\n\
  thaiword => '( definition english_classifier thai_classisifier)\n\ 
where each element is a lists of strings.  empty definitions should be\n\
defined as nil:\n\n\
  \"ไก่\" => ( '(\"chicken\") nil '(\"ตัว\") ) \n\n\
if a thai word is known but without definitions its entry will be \n\
 '( nil nil nil )")

(defconst pthai-consonants
  (list
   (split-string "กขฃคฅฆงจฉชซณญฎฏฑฒณฐดตถทนบธปผฝพฟภมยรลวศษสหฬอฮ" "" t)
   (split-string "mhhllllmhllllmmlllhmmhllmlmhhllllllllhhhhlml" "" t))
  "list containing a list of the thai consonants, and a list of
  their classes (low mid high)")

;; https://www.omniglot.com/writing/thai.htm
(defconst pthai-vowels
  (list "อะ" "อั" "อา" "อิ" "อี" "อึ" "อื"
	"อีอ" "อุ" "อู" "เอะ" "เอ็" "เอ" "แอะ"
	"แอ็" "แอ" "โอะ" "โอ" "เอาะ" "อ็อ" "ออ"
	"อ็" "เออะ" "เออ" "เอิ"
	"เอียะ" "เอีย" "เอือะ" "เอือ" "อัวะ" "อัว" "อว"
	"อิว" "เอ็อ" "เอว" "แอว" "เอา" "อาว" "เอียว"
	"อัย" "ใอ" "ไอ" "ไอย" "อาย" "อ็อย" "ออย"
	"โอย" "อุย" "เอย" "อวย" "เอือย" "อำ" "ฤ"
	"ฤๅ" "ฦ" "ฦๅ" "อํ" "อ์")
  "list of thai vowels (replace first 'อ' with consonant)")

(defconst pthai-numbers
  (split-string "๐๑๒๓๔๕๖๗๘๙" "" t)
  "list of thai numbers")

(defconst pthai-regexp-single-consonant
  "^[\u0e01-\u0e2e]\\{1\\}$"
  "single unicode thai consonant [ก ข ฃ ... อ ฮ]")

(defconst pthai-regexp-thai-number
  "^[\u0e50-\u0e59]+$"
  "thai unicode number [๐๑๒๓๔๕๖๗๘๙]")

(defconst pthai-regexp-any-number
  "^\\([\u0e50-\u0e59]+\\|[0-9]+\\)$"
  "number [0-9] or thai unicode number [๐๑๒๓๔๕๖๗๘๙]")

(defconst pthai-regexp-spelling-chars
  "\\([\u0e01-\u0e3a\u0e40-\u0e4f]+\\)"
  "thai unicode characters that are matched for spelling")

(defconst pthai-regexp-valid-standalone-char
  "\\([\u0e13\u0e2f\u0e3a\u0e3f\u0e46\u0e4d\u0e4f\u0e50-\u0e5b]+\\)"
  "thai unicode characters valid as a stand alone character [ณฯฺ฿ๆํ๏๚๛๐๑๒๓๔๕๖๗๘๙]")

(defvar pthai-rwb-tmp nil "temporary variable for pthai-rwb") 

(defgroup pthai nil
  "Pthai dictionary, word-splitting and audio-playing for Thai words"
  :group 'applications)

(defcustom pthai-split-mode "biggest"
  "Word splitting strategy"
  :group 'pthai
  :set (lambda (sym val) (set-default sym val))
  :type '(radio
	  (const :tag "biggest words possible/fewest words" :value "biggest")
	  (const :tag "smallest words possible/most words" :value "smallest")
	  (const :tag "fewest misspelled words and spelling suggestions" :value "spelling")
	  (const :tag "interactively display choices" :value "interactive")))

(defcustom pthai-rwb-max-word-length 60
  "maximum string length of thai string used by recursive program
'pthai-rwb when splitting words. if a string is longer than this
value, don't use 'pthai-rwb for splitting.  set to 0 to disable
pthai-rwb splitting"
  :group 'pthai
  :type 'integer)

(defcustom pthai-fetch-clickthai t
  "fetch mp3 audio from clickthai-online.com"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-fetch-lingopolo t
  "fetch mp3 audio from lingopolo.com"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-fetch-thai2english t
  "fetch mp3 audio from thai2english.com"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-fetch-thai-language t
  "fetch mp3 audio from thai-language.com"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-fetch-silence t
  "disable messaging when fetching mp3 urls and files"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-audio-download 1
  "when to download audio files for words"
  :group 'pthai
  :set (lambda (sym val) (set-default sym val))
  :type '(radio
	  (const :tag "never (off)" :value 0)
	  (const :tag "normal (words without attempted downloads)" :value 1)
	  (const :tag "always (only use for testing)" :value 2)))

(defcustom pthai-audio-display-definition t
  "display definition of words in *Message* buffer and echo area
when playing audio for thai words"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-audio-play t
  "play audio for thai words when t, do not play if nil"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-audio-strip-silence t
  "when playing audio words strip the silences from them.  this
speeds up playing and makes playing multiple words sound more natural"
  :group 'pthai
  :type 'boolean)

(defcustom pthai-audio-missing-pause 1.3
  "when playing audio for words, length of time in seconds to
pause on words that don't have audio available.  disable if <= 0.0"
  :group 'pthai
  :type 'float)

(defcustom pthai-audio-directory
  (expand-file-name "audio" pthai-default-directory)
  "directory where mp3 audio files are downloaded to and read from"
  :group 'pthai
  :set (lambda (sym val)
	 (set-default sym val)
	 (and (not load-in-progress)
	      (not (file-directory-p val))
	      (yes-or-no-p (concat val " does not exist, create?"))
	      (make-directory val)))
  :type 'directory)

(defun pthai-highlight-on(beg end)
  "highlight bounds of text using overlays, mark as a 'pthai overlay"
  (let ((ol (make-overlay beg end)))
    (overlay-put ol 'pthai t)
    (overlay-put ol 'face '(:background "#FFFFAA"))
    (overlay-put ol 'evaporate t)))

(defun pthai-highlight-off(beg end)
  "remove overlay highlight at bounds. only removes 'pthai overlays"
  (remove-overlays beg end 'pthai t))

(defun pthai-message(bname str &optional forceb)
  "if str large or option forceb, create new buffer named 'bname
 and switch to it, else just use 'message with str"
  (if (and (< (length str) 80)
	   (not forceb))
      (message str)
    (set-buffer (get-buffer-create bname))
    (set-buffer-multibyte t)
    (erase-buffer)
    (insert str)
    (goto-char (point-min))
    (switch-to-buffer-other-window bname)))

(defun pthai-completing-read-full(prompt options &optional default)
  "wrapper for 'completing-read to display full choice list initially"
  (minibuffer-with-setup-hook 'minibuffer-completion-help
    (completing-read prompt options nil nil default)))

(defun pthai-completing-read-dynamic(prompt &optional prefix)
  "wrapper for 'completing-read to dynamically calculate choices
from all words in 'pthai-dictionary starting with opt prefix
(use C-q SPC to enter spaces in minibuffer prompt)"
  (completing-read
   prompt
   (completion-table-dynamic (lambda(x) (all-completions x pthai-dictionary)))
   nil nil prefix))

(defun pthai-nrandomize(lst)
  "return lst in random order. lst will be destructively modified in place"
  (let ((ltmp lst)
	tlist tmp)
    (while ltmp
      (setq tlist (nthcdr (random (length ltmp)) ltmp)
	    tmp (car tlist))
      (setcar tlist (car ltmp))
      (setcar ltmp tmp)
      (setq ltmp (cdr ltmp)))
    lst))

;; utilities to manipulate the 'thai-word-table from the 'thai-word
;; library.  these functions could be merged into the 'thai-word
;; module itself, but that module looks to have been written when
;; 'thai-tis620 encoding was commonly used instead of utf-8 for thai
;;
;; 'thai-word-table uses a nested-alist from mule-util which looks
;; like a trie:
;;  '(thai-words
;;    (3585 1                        ;; ก      word   ก
;;     (3591 1                       ;;  ง     word   กง
;;           (3585 t                 ;;    ก
;;                 (3634 t           ;;     า
;;                       (3619 1)))) ;;      ร word   กงการ
;;     (3585 1                       ;;  ก     word   กก
;;           (3621 1))))             ;;   ล    word   กกล
;;
(defun pthai-twt-words(&optional prefix)
  "return unsorted list of all thai words from 'thai-word-table.
if optional arg prefix, find all word starting with prefix inclusively"
  (let ((plen (length prefix))
	(todo (cdr thai-word-table))
	alist word words)
    (if (< plen 1)
	;; prepend null char so don't have to test later
	(setq todo (mapcar (lambda(x) (list "" t x)) todo))
      (catch 'nfound
	(dotimes (i plen)
	  (or (setq todo (assq (aref prefix i) todo))
	      (throw 'nfound nil))
	  (if (/= (1+ i) plen)
	      (setq todo (cddr todo))
	    (setq todo (list (cons (concat (substring prefix 0 (1- plen))
					   (char-to-string (car todo)))
				   (cdr todo))))))))
    (while todo
      (setq alist (car todo)
	    word (car alist)
	    todo (cdr todo))
      ;; save word ("กง" 1 ...)
      (if (eq (cadr alist) 1)
	  (setq words (cons word words)))
      ;; merge ("ษั" t ((3625 1 (3600 1)) (3637 1) (3632 1)))
      (dolist (tmp (cddr alist))
       	(setq todo (cons
		    (cons (concat word (char-to-string (car tmp)))
			  (cdr tmp))
		    todo))))
    words))

(defun pthai-twt-p(thaiword)
 "return t if thaiword is in 'thai-word-table, nil otherwise"
 (eq 1 (car (lookup-nested-alist
	     thaiword thai-word-table (length thaiword) 0 t))))

(defun pthai-twt-add(thaiword)
  "add thaiword to thai-word-table"
  (set-nested-alist thaiword 1 thai-word-table))

(defun pthai-twt-clear()
  "clear all entries from thai-word-table"
  (setq thai-word-table (list 'thai-words)))

(defun pthai-twt-remove(thaiword)
  "remove thaiword from thai-word-table.  return t if word
removed from table else nil"
  (let ((f_cword  (lambda(lst) (eq 1 (cadr lst))))         ;; complete word
	(f_branch (lambda(lst) (> (length (cddr lst)) 1))) ;; sub-branches
	(f_term   (lambda(lst) (< (length lst) 3)))        ;; terminal node
	(klen (length thaiword))
	(tmp (cdr thai-word-table))
	br_tmp br_idx)
    (unless (< klen 1)
      ;; go to last node/letter, keep track of deepest shared letter
      (catch 'rdone
	(dotimes (i klen)
	  (or (setq tmp (assq (aref thaiword i) tmp))
	      (throw 'rdone nil))
	  (unless (= (1+ i) klen)
	    (when (or (funcall f_cword tmp) (funcall f_branch tmp))
	      (setq br_tmp tmp br_idx (1+ i)))
	    (setq tmp (cddr tmp)))))
      ;; one of: word not in table, all letters in table though not word,
      ;; delete prefix word, delete at deepest unshared node, or delete branch
      (cond
       ((not tmp)
	nil)
       ((not (funcall f_cword tmp))
	nil)
       ((not (funcall f_term tmp))
	(setf (nth 1 tmp) t))
       (br_tmp
	(setf (alist-get (aref thaiword br_idx) br_tmp nil 'remove) nil)
	t)
       (t
	(setf (alist-get (aref thaiword 0) (cdr thai-word-table) nil 'remove) nil)
	t)))))

(defun pthai-twt-count()
  "return number of words in the 'thai-word-table from the
'thai-word module.  message user when called interactively"
  (let ((count (length (pthai-twt-words))))
    (when (called-interactively-p 'any)
      (message "%d words" count))
    count))

(defun pthai-twt-read(filename)
  "read file containing list of thaiwords into
'thai-word-table. file needs to be in utf-8 format, one thaiword
per line, no spaces.  returns number of words read in file"
  (let ((coding-system-for-read 'utf-8)
 	(buffer-file-coding-system 'utf-8)
	(count 0))
    (with-temp-buffer
      (insert-file-contents filename)
      (while (re-search-forward "^\\(\\ct+\\)\n" nil t)
	(pthai-twt-add (match-string 1))
	(setq count (1+ count))))
    (message "%d words read in %s" count filename)
    count))

(defun pthai-twt-save(filename)
  "save sorted thai words extracted from 'thai-word-table to
filename in utf8 format, one word per line.  returns number of
words saved"
  (let* ((buffer-file-coding-system 'utf-8)
	 (coding-system-for-write 'utf-8)
	 (words (sort (pthai-twt-words) 'string<)))
    (with-temp-buffer
      (dolist (word words)
	(insert word "\n"))
      (write-region nil nil filename)
      (length words))))

(defun pthai-twt-split(text)
  "split thai string using 'thai-break-words. return list with a
string of split words"
  (with-temp-buffer
    (insert text)
    (goto-char (point-min))
    (thai-break-words " ")
    (list (buffer-string))))

(defun pthai-rwb(thaistr)
  "return list of space separated strings of possible word breaks
for a thai string, uses recursion (recursive word break).  can
only handle known (i.e., in dictionary) thai words.  if no word
breaks found, return '(thaistr)."
  (unless (string-match-p "^\\ct+$" thaistr)
    (error "cannot split non-thai strings: %s" thaistr))
  (setq pthai-rwb-tmp nil)
  (pthai-rwb-helper thaistr (length thaistr) "")
  (or pthai-rwb-tmp
      (list thaistr)))

(defun pthai-rwb-helper(str n result)
  "recursive helper function for pthai-rwb"
  (let ((i 1)
	prefix
	ss)
    (catch 'done
      (while (<= i n)
	(setq prefix (substring-no-properties str 0 i))
	(when (pthai-dictionary-p prefix)
	  (when (= i n)
	    (setq result (concat result prefix)
		  pthai-rwb-tmp (cons result pthai-rwb-tmp))
	    (throw 'done nil))
	  (setq ss (substring-no-properties str i))
	  (pthai-rwb-helper ss (length ss) (concat result prefix " ")))
	(setq i (1+ i))))))

(defun pthai-split-all(text)
  "split thai string into words. only understands single line
thai character strings.  returns all found word segmentation
combinations as a list of strings, where each string is a group
of words separated by spaces."
  (delete-dups
   (append
    ;; xxx pthai-rwb returns all possible combos
    (and (<= (length text) pthai-rwb-max-word-length)
	 (pthai-spell-p text)
	 (pthai-rwb text))
    (pthai-twt-split text))))

(defun pthai-split-best(text &optional smode)
  "split up thai string into words.  words are broken up
depending on 'pthai-split-mode setting and words available in
'pthai-dictionary.  split mode can be specified with optional arg
smode using same values as 'pthai-split-mode setting.  returns a
string containing words separated by spaces"
  (let (slist)
    (unless smode
      (setq smode pthai-split-mode))
    (cond
     ;; biggest/smallest
     ((or (string= smode "biggest")
	  (string= smode "smallest"))
      (setq slist
	    (sort (mapcar 'split-string (pthai-split-all text))
		  (if (string= smode "biggest")
       		      (lambda (a b) (< (length a) (length b)))
       		    (lambda (a b) (> (length a) (length b))))))
      (string-join
       (or (seq-find (lambda(x) (seq-every-p 'pthai-dictionary-p x)) slist)
	   (car slist))
       " "))
     ;; spelling
     ((string= smode "spelling")
       (setq slist (pthai-spell-suggestions text)
       	     slist (pthai-spell-sort slist))
       (string-join (caar slist) " "))
     ;; interactively
     ((string= smode "interactive")
      (pthai-completing-read-full "split words: " (pthai-split-all text)))
     (t
      (error "invalid split mode (%s)" smode)))))

(defun pthai-bounds-offsets(pt slist)
  "helper function to calculate the bounds of contiguous thai
string starting at point pt.  slist is the thai string already
split into a list"
  (mapcar
   (lambda(x) (cons pt (setq pt (+ pt (length x)))))
   slist))

(defun pthai-bounds-of-thaistring-at-point(&optional cpoint)
  "return beginning and end points of thai string as a cons at
point or nil.  use optional arg cpoint instead of point if given.
no word splitting is done.  try to emulate behavior of
bounds-of-thing-at-point when point past end of string"
  (save-excursion
    (let* ((pt (if cpoint (goto-char cpoint) (point)))
	   (o1 (skip-chars-backward "\u0e00-\u0e7f"))
	   (o2 (progn (goto-char pt)
		      (skip-chars-forward "\u0e00-\u0e7f"))))
      (if (or (/= o2 0)
	      (and (/= o1 0) (looking-at-p "\\W\\|\\'")))
	  (cons (+ pt o1) (+ pt o2))))))

(defun pthai-bounds-of-thaiwords-at-point(&optional cpoint smode)
  "return a list of word bounds for a contiguous thai string at
point. does word splitting to calculate bounds.  returns nil if
point not at a thai string.  bounds are returned in same
order as found in buffer"
  (let ((bounds
	 (pthai-bounds-of-thaistring-at-point (or cpoint (point)))))
    (and bounds
	 (pthai-bounds-offsets
	  (car bounds)
	  (split-string
	   (pthai-split-best
	    (buffer-substring-no-properties (car bounds) (cdr bounds))
	    (or smode pthai-split-mode)))))))

(defun pthai-bounds-of-thaiword-at-point(&optional cpoint smode)
  "Return beginning and end boundaries of a thai word at point.  does
word-splitting to determine the actual thai word.  used with
thingatpoint library to allow 
  (thing-at-point 'thaiword)
  (forward-thaiword)
  (backward-thaiword)
  (beginning-of-thing 'thaiword)
  (end-of-thing 'thaiword)
to work (others?).  returns nil if boundaries not found"
  (let* ((pt (or cpoint (point)))
	 (bounds
	  (pthai-bounds-of-thaiwords-at-point pt (or smode pthai-split-mode))))
    ;; find point within a bounds, or point must be just past last bounds
    (and bounds
     	 (or (seq-find (lambda (x) (< pt (cdr x))) bounds)
	     (car (last bounds))))))

;; add to thingatpoint library
(put 'thaiword 'bounds-of-thing-at-point
     'pthai-bounds-of-thaiword-at-point)

(defun pthai-beginning-of-thaiword()
  "move point to beginning of thaiword"
  (beginning-of-thing 'thaiword))

(defun pthai-end-of-thaiword()
  "move point to end of thaiword"
  (end-of-thing 'thaiword))

(defun forward-thaiword(&optional arg)
  "Move point forward 1 or arg thaiwords (backward if arg is
negative). returns t if moves arg times successfully, else nil.
skips over non-thai text.  (see also 'thai-forward-word)"
  (interactive "p")
  (let ((success t)
	tmp)
    (unless arg (setq arg 1))
    ;; backward - move to beginning of closest thaiword backward from point
    (while (and (< arg 0) success)
      (cond
       ((bobp)
	(setq success nil))
       ((looking-at-p "\\ct")
	(setq tmp (car (pthai-bounds-of-thaiword-at-point)))
	(when (= (point) tmp)
	  (setq tmp (1- tmp))
	  (setq arg (1- arg)))
	(goto-char tmp))
       ((re-search-backward "\\ct" nil t)
	(goto-char (car (pthai-bounds-of-thaiword-at-point))))
       (t
	(setq success nil)))
      (setq arg (1+ arg)))
    ;; forward - move to end of closest thai word going right
    (while (and (> arg 0) success)
      (cond
       ((looking-at-p "\\ct")
	(goto-char (cdr (pthai-bounds-of-thaiword-at-point))))
       ((re-search-forward "\\ct" nil t)
	(goto-char (cdr (pthai-bounds-of-thaiword-at-point (1- (point))))))
       (t
	(setq success nil)))
      (setq arg (1- arg)))
    success))

(defun backward-thaiword(&optional arg)
  "move backward until encountering the beginning of an thai
  word.  with arg, do arg times. (see also 'thai-backword-word)"
  (interactive "p")
  (forward-thaiword (- 0 (or arg 1))))

(defun pthai-kill-word(&optional arg)
  "kill thai word, but only match against thai words, accounting
for thai word boundaries.  kills spaces and non-thai characters
between thai words"
  (interactive "p")
  (let ((arg (or arg 1))
	(b_pt (point))
	e_pt done)
    (while (and (> arg 0) (not done))
      (cond
       ((looking-at-p "\\ct")
	(setq e_pt (cdr (pthai-bounds-of-thaiword-at-point)))
	(goto-char e_pt)
	(setq arg (1- arg)))
       ((forward-thaiword)
	(goto-char (1- (point)))
	(setq e_pt (cdr (pthai-bounds-of-thaiword-at-point)))
	(goto-char e_pt)
	(setq arg (1- arg)))
       (t
	(setq done t))))
    (while (and (< arg 0) (not done))
      (cond
       ((backward-thaiword)
	(setq e_pt (car (pthai-bounds-of-thaiword-at-point)))
	(goto-char (1- e_pt))
	(setq arg (1+ arg)))
       (t
	(setq done t))))
    (when e_pt
      (if (> e_pt b_pt)
	  (kill-region b_pt e_pt)
	(goto-char (1+ (point)))
	(kill-region e_pt b_pt)))))

(defun pthai-backward-kill-word(arg)
  "same as backward-kill-word but accommodate thai word boundaries"
  (interactive "p")
  (pthai-kill-word (- (or arg 1))))

(defun pthai-transpose-words(&optional arg)
  "similar to 'transpose-words, but accommodate thai word
boundaries, ignoring non-thai text"
  (interactive "p")
  (let ((arg (or arg 1))
	b1 b2 tmp tmp2 mark_lt)
    (cond
     ((= arg 0)
      (save-mark-and-excursion
	;; find thai words near point and mark
	(setq b1 (or (pthai-bounds-of-thaiword-at-point)
		     (and (forward-thaiword)
			  (pthai-bounds-of-thaiword-at-point (1- (point)))))
	      b2 (and (mark)
		      (goto-char (mark))
		      (or (pthai-bounds-of-thaiword-at-point)
			  (and (forward-thaiword)
			       (pthai-bounds-of-thaiword-at-point (1- (point)))))))
	(unless (and b1 b2)
	  (error "could not find 2 thai words")))
      (if (> (car b1) (car b2))
	  (setq tmp b1
		b1 b2
		b2 tmp
		mark_lt t))
      (setq tmp (buffer-substring (car b1) (cdr b1))
	    tmp2 (buffer-substring (car b2) (cdr b2)))
      (goto-char (cdr b2))
      (kill-region (car b2) (cdr b2))
      (insert tmp)
      (goto-char (cdr b1))
      (kill-region (car b1) (cdr b1))
      (insert tmp2)
      ;; move cursor to front/end of original word it was by,
      ;; depending on new position
      (goto-char (if mark_lt (car b1) (cdr b2))))
     ((> arg 0)
      (save-excursion
	;; find boundaries of thai word to move
	(setq b1 (or (pthai-bounds-of-thaiword-at-point)
		     (and (backward-thaiword)
			  (pthai-bounds-of-thaiword-at-point))))
	;; find non-thai boundaries
	(setq b2 (and b1
		      (goto-char (cdr b1))
		      (forward-thaiword)
		      (backward-thaiword)
		      (cons (cdr b1) (point))))
	;; check bounds found and move to insertion point
	(unless (and b1 b2
		     (setq tmp (and (forward-thaiword arg) (point))))
	  (error "could not find thai words")))
      (goto-char tmp)
      (insert (buffer-substring (car b2) (cdr b2)))
      (insert (buffer-substring (car b1) (cdr b1)))
      (kill-region (car b1) (cdr b2)))
     ((< arg 0)
      (save-excursion
	;; bounds of word near point
	(setq b1 (or (pthai-bounds-of-thaiword-at-point)
		     (and (backward-thaiword)
			  (pthai-bounds-of-thaiword-at-point))))
	;; non-thai bounds before b1
	(setq b2 (and b1
		      (goto-char (car b1))
		      (backward-thaiword)
		      (forward-thaiword)
		      (cons (point) (car b1))))
	(unless (and b1 b2
		     (setq tmp (and (forward-thaiword arg) (point))))
	  (error "could not find thai words")))
      (goto-char tmp)
      (setq tmp (buffer-substring (car b1) (cdr b1))
	    tmp2 (buffer-substring (car b2) (cdr b2)))
      (kill-region (car b2) (cdr b1))
      (insert tmp tmp2)
      (goto-char (- (point) (length tmp2)))))))

(defun pthai-copy-word-at-point()
  "save thai word at point to kill ring with kill-ring-save, or if
point not at thai word, save word with thing-at-point"
  (interactive)
  (let ((bounds (or (pthai-bounds-of-thaiword-at-point)
		    (bounds-of-thing-at-point 'word))))
    (when bounds
      (pulse-momentary-highlight-region (car bounds) (cdr bounds))
      (kill-ring-save (car bounds) (cdr bounds)))))

(defun pthai-copy-string-at-point()
  "save thai string at point to kill ring with kill-ring-save, or
if point not at thai string, save word with thing-at-point"
  (interactive)
  (let ((bounds (or (pthai-bounds-of-thaistring-at-point)
		    (bounds-of-thing-at-point 'word))))
    (when bounds
      (pulse-momentary-highlight-region (car bounds) (cdr bounds))
      (kill-ring-save (car bounds) (cdr bounds)))))

(defun pthai-split-region(p1 p2)
  "in a region defined by p1 and p2, split contiguous thai
strings into words separated by spaces"
  (interactive "r")
  (let (bounds)
    (save-excursion
      (save-restriction
	(narrow-to-region p1 p2)
	(goto-char (point-min))
	;; divide up thai words with no spaces between
	(while (re-search-forward "\\(\\ct+\\)" nil t)
	  (when (and (setq bounds
			   (pthai-bounds-of-thaiwords-at-point (match-beginning 1)))
		     (> (length bounds) 1))
	    (insert
	     (string-join
	      (mapcar (lambda (x) (buffer-substring (car x) (cdr x))) bounds)
	      " "))
	    (delete-region (caar bounds) (cdar (last bounds)))))
	;; append spaces
	(goto-char (point-min))
	(while (re-search-forward "\\(\\ct\\)\\(\\Ct\\)" nil t)
	  (unless (string-match-p "[[:space:]]" (match-string 2))
	    (replace-match (concat (match-string 1) " " (match-string 2)))))
	;; prepend spaces
	(goto-char (point-min))
	(while (re-search-forward "\\(\\Ct\\)\\(\\ct\\)" nil t)
	  (unless (string-match-p "[[:space:]]" (match-string 1))
	    (replace-match (concat (match-string 1) " " (match-string 2)))))))))

(defun pthai-split(text)
  "return thai string with spaces between split words"
  (interactive (list (pthai-completing-read-dynamic "text: " nil)))
  (with-temp-buffer
    (insert text)
    (pthai-split-buffer)
    (when (called-interactively-p 'any)
      (message "%s" (buffer-string)))
    (buffer-string)))

(defun pthai-split-string-at-point()
  "split thai string at point, returns nil if point not at thai string"
  (interactive)
  (let ((bounds (pthai-bounds-of-thaistring-at-point)))
    (and bounds
	 (pthai-split-region (car bounds) (cdr bounds)))))

(defun pthai-split-line()
  "split thai words on current line"
  (interactive)
  (pthai-split-region (line-beginning-position) (line-end-position)))

(defun pthai-split-line-from-point()
  "split thai words from point, or beginning of thai word at point,
to the end of the line"
  (interactive)
  (pthai-split-region
   (or (car (pthai-bounds-of-thaiword-at-point)) (point))
   (line-end-position)))

(defun pthai-split-buffer()
  "split thai words in buffer"
  (interactive)
  (pthai-split-region (point-min) (point-max)))

(defun pthai-unsplit-region(p1 p2)
  "remove space and tabs from thai words in region"
  (interactive "r")
  (let* ((nonnum_rexp "\\([\u0e00-\u0e4f\u0e5a-\u0e7f]\\)")
	 (space_regexp
	  (concat nonnum_rexp "[ \t]+" nonnum_rexp)))
    (save-excursion
      (save-restriction
	(narrow-to-region p1 p2)
	(goto-char (point-min))
	(while (re-search-forward space_regexp nil t)
	  (replace-match (concat (match-string 1) (match-string 2))))))))

(defun pthai-unsplit-line()
  "remove spaces from thai words on current line"
  (interactive)
  (pthai-unsplit-region (line-beginning-position) (line-end-position)))

(defun pthai-unsplit-line-from-point()
  "remove spaces from thai words on line starting at point"
  (interactive)
  (pthai-unsplit-region (point) (line-end-position)))

(defun pthai-unsplit-buffer()
  "remove spaces from thai words in buffer"
  (interactive)
  (pthai-unsplit-region (point-min) (point-max)))

(defun pthai-url-clickthai(thaiword)
  "return mp3 url for a thai word from clickthai-online.com or
nil if unavailable"
  (let ((url "https://www.clickthai-online.com/")
	(url-request-method "POST")
	(url-request-extra-headers
	 `(("Content-Type" . "application/x-www-form-urlencoded; charset=utf-8")))
	(url-request-data
	 (concat "action=search&search=" (url-encode-url thaiword)))
	(click_url
	 (concat "\\(cgi-bin/playmp3\\.pl\\?[0-9]+\\)');\">" thaiword "</a>")))
    ;; tests: ผู้ชาย วัน
    (with-current-buffer
	(url-retrieve-synchronously (concat url "wbtde/dictionary.php"))
      (set-buffer-multibyte t)
      (goto-char (point-min))
      (and (re-search-forward click_url nil t 1)
	   (concat url (match-string 1))))))

(defun pthai-url-thai-language(thaiword)
  "return mp3 url for a thai word from thai-language.com or nil
if unavailable"
  (let ((url "http://thai-language.com/")
	(url-request-method "POST")
	(url-request-extra-headers
	 `(("Content-Type" . "application/x-www-form-urlencoded; charset=utf-8")))
	(url-request-data
 	 (concat "action=/default.aspx&tmode=0&search=" (url-encode-url thaiword)))
	(click_regexp
	 "<a onClick=PlayAudioFile('/\\(mp3/[A-Z][0-9]+\\.mp3\\)')")
	sound_url tmp)
    ;; tests: แมว หำ วร สระ ตัว ความเสียหาย วรรค, nils บักหำ กะปิ
    (with-current-buffer (url-retrieve-synchronously (concat url "dict"))
      (set-buffer-multibyte t)
      (goto-char (point-min))
      (while (and (not sound_url)
		  (re-search-forward click_regexp nil t))
	(and (setq tmp (cons (match-end 1) (match-string 1)))
	     (re-search-backward "<tr[ >]" nil t)
	     (string= thaiword
		      (replace-regexp-in-string
		       "\\Ct+" "" (buffer-substring-no-properties (point) (car tmp))))
	     (setq sound_url (concat url (cdr tmp))))
	(goto-char (car tmp)))
      sound_url)))

(defun pthai-url-thai2english(thaiword)
  "return mp3 url for a thai word from thai2english.com or nil if
unavailable"
  (let ((fetch_url
	 (concat "https://www.thai2english.com/api/search?q=" (url-encode-url thaiword)))
	(sounds_url "https://ast.thai2english.com/sounds/")
	json proc fire id)
    ;; tests: เป็น กรุงเทพ คณะ คง ประเทศไทย มากกว่า, nils แมว เป็นน
    (with-current-buffer (url-retrieve-synchronously fetch_url)
      (set-buffer-multibyte t)
      (goto-char (point-min))
      (search-forward "\n\n")
      (setq json (json-parse-buffer :object-type 'hash-table))
      (and (string= thaiword (gethash "preprocessedQuery" json))
	   (setq proc (gethash "processed" json))
	   (setq fire (gethash "firestoreWord" proc))
	   (eq t (gethash "hasSoundFile" fire))
	   (setq id (gethash "legacyWordId" fire))
	   (concat sounds_url (number-to-string id) ".mp3")))))

(defun pthai-url-lingopolo(thaiword)
  "return mp3 url for a thai word from lingopolo.org or nil if
unavailable"
  (let* ((lingo_url "https://lingopolo.org")
	 (search_url
	  (concat lingo_url "/thai/search/node?keys=" (url-encode-url thaiword)))
	 (yes_regexp
	  (concat "<strong>" thaiword "</strong>[[:space:]]+…</p>"))
	 (no_match "Your search yielded no results.")
	 ;; <a href="https://lingopolo.org/thai/word/also-well">also; well...</a>
	 (found_regexp
	  (concat "<a href=\"\\(" lingo_url "/thai/word/.+\\)\">.+</a>"))
	 (found_url nil)
	 (audio_regexp
	  "<audio src=\"\\(/thai/sites/.+\\.mp3\\)\" controls="))
    ;; tests แมว เอ้อ เสือ คน หนอนผีเสื้อ nils หำ บักหำ
    (with-current-buffer (url-retrieve-synchronously search_url)
      (set-buffer-multibyte t)
      (goto-char (point-min))
      (and (not (search-forward no_match nil t))
	   (goto-char (point-min))
	   (re-search-forward yes_regexp nil t)
	   (re-search-backward found_regexp nil t)
	   (setq found_url (match-string 1))))
    (when found_url
      (with-current-buffer (url-retrieve-synchronously found_url)
	(set-buffer-multibyte t)
	(goto-char (point-min))
	(and (re-search-forward audio_regexp nil t)
	     (concat lingo_url (match-string 1)))))))

(defun pthai-soundfile(thaiword)
  "generate default sound path for mp3 audio file for
thaiword. file may or may not exist already"
  (unless (string-match-p "^\\ct+$" thaiword)
    (error "only thai words supported (%s)" thaiword))
  (unless (and pthai-audio-directory
	       (file-directory-p pthai-audio-directory))
    (error "pthai-audio-directory (%s) not found"  pthai-audio-directory))
  (let ((subdir
	 (expand-file-name (number-to-string (string-to-char thaiword))
			   pthai-audio-directory)))
    (unless (file-directory-p subdir)
      (make-directory subdir))
    (expand-file-name (concat thaiword ".mp3") subdir)))

(defun pthai-soundfile-downloaded-p(thaiword)
  "return non-nil if a soundfile download already attempted for a
thai word"
  (file-exists-p (pthai-soundfile thaiword)))

(defun pthai-soundfile-playable-p(thaiword)
  "return non-nil if a soundfile downloaded successfully for a thai word"
  (let ((tpath (pthai-soundfile thaiword)))
    (and (file-exists-p tpath)
	 (> (file-attribute-size (file-attributes tpath)) 0))))

(defun pthai-soundfiles-all()
  "return sorted list of thai words for all soundfiles download attempts"
  (interactive)
  (let ((slist
	 (sort (mapcar
		'file-name-base	(directory-files-recursively
				 pthai-audio-directory "^\\ct+\\.[Mm][Pp]3$"))
	  'string-lessp)))
    (when (called-interactively-p 'any)
      (message "%s" (string-join slist " ")))
    slist))

(defun pthai-counts-by-letter(bname words)
  "helper function to display counts of words by letter in a
buffer.  bname is name of buffer and words is a list of thai words"
  (let ((buf (get-buffer-create bname)))
    (switch-to-buffer buf)
    (set-buffer-multibyte t)
    (erase-buffer)
    (mapc (lambda(x) (insert (format "%s %d\n" (car x) (length (cdr x)))))
	  (seq-group-by (lambda(y) (substring y 0 1)) words))
    (sort-lines nil (point-min) (point-max))
    (insert (format "\ntotal %d\n" (length words)))
    (goto-char (point-min))))

(defun pthai-soundfiles-counts()
  "display number of soundfiles downloaded for all words by
letter and total overall"
  (interactive)
  (pthai-counts-by-letter
   "*pthai-soundfiles-counts*" (pthai-soundfiles-all)))

(defun pthai-soundfiles-unplayable()
  "return list of thai words that don't have soundfile available
after a download attempt"
  (interactive)
  (let ((rlist
	 (seq-remove 'pthai-soundfile-playable-p (pthai-soundfiles-all))))
    (when (called-interactively-p 'any)
      (message "%s" (string-join rlist " ")))
    rlist))

(defun pthai-soundfiles-playable()
  "return list of all thai words with downloaded soundfile"
  (interactive)
  (let ((rlist
	 (seq-filter 'pthai-soundfile-playable-p (pthai-soundfiles-all))))
    (when (called-interactively-p 'any)
      (message "%s" (string-join rlist " ")))
    rlist))

(defun pthai-soundfiles-count()
  "return total number of soundfiles downloaded, message user
total/playable/unplayable when called interactively"
  (interactive)
  (let ((total 0)
	(play 0)
	(uplay 0))
    (dolist (f (pthai-soundfiles-all))
      (if (pthai-soundfile-playable-p f)
	  (setq play (1+ play))
	(setq uplay (1+ uplay)))
      (setq total (1+ total)))
    (when (called-interactively-p 'any)
      (message "%d/%d/%d total/playable/unplayable soundfiles" total play uplay))
    total))

(defun pthai-soundfiles-sort(a b)
  "sorter for a list of thai strings in order of most available
audio files as a percentage, or containing the bigger words"
  (let* ((a_total (length a))
	 (b_total (length b))
	 (a_found (length (seq-filter 'pthai-soundfile-playable-p a)))
	 (b_found (length (seq-filter 'pthai-soundfile-playable-p b)))
	 (a_percent (/ (float a_found) (float a_total)))
	 (b_percent (/ (float b_found) (float b_total))))
    (cond
     ;; use biggest length of first word if all rest same
     ((and (= a_total b_total)
	   (= a_percent b_percent))
      (> (length (car a)) (length (car b))))
     ;; use larger words if percentage same
     ((= a_percent b_percent)
      (< a_total b_total))
     ;; else highest percent of words available
     (t
      (> a_percent b_percent)))))

(defun pthai-mp3-play(thaiword)
  "use sox/play to play audio file for a thai word. attempts to
remove silences from audio file unless 'pthai-audio-strip-silence
is nil.  return t if playing successful, nil otherwise. returns
nil if soundfile not available, 'pthai-audio-play disabled, or
some error"
  ;; xxx set coding-system* utf-8, xxx "WARN .*rate clipped"
  (when (and pthai-audio-play
	     (pthai-soundfile-playable-p thaiword))
    (let ((stat
	   (apply 'call-process
		  (append
		   (list "play" nil nil nil "-q" (pthai-soundfile thaiword))
		   (if pthai-audio-strip-silence
		       (split-string
			"silence 1 0.1 1% reverse silence 1 0.1 1% reverse"))))))
      (or (= stat 0)
	  (progn
	    (warn "non-zero exit (%d) for play of %s"
		  stat (pthai-soundfile thaiword))
	    nil)))))

(defun pthai-mp3-url(thaiword)
  "return mp3 sound url for thai word from random one of
thai-language, clickthai, lingopolo or thai2english, or nil if
no url not found"
  (let ((fetchers
	 (pthai-nrandomize
	  (list (and pthai-fetch-clickthai 'pthai-url-clickthai)
		(and pthai-fetch-lingopolo 'pthai-url-lingopolo)
		(and pthai-fetch-thai2english 'pthai-url-thai2english)
		(and pthai-fetch-thai-language 'pthai-url-thai-language)))))
    (and (string-match-p "\\ct" thaiword)
	 (seq-some (lambda(x) (and x (funcall x thaiword))) fetchers))))

(defun pthai-mp3-fetch(thaiword)
  "fetch mp3 soundfile to audio directory. returns path to
downloaded file or nil if unable to download. if download
unsuccesful, write empty file with name of thai word to default path"
  (let* ((inhibit-message pthai-fetch-silence)
	 (file (pthai-soundfile thaiword))
	 (url (pthai-mp3-url thaiword)))
    (cond
     (url
      (message "%s url = %s" thaiword url)
      (url-copy-file url file t)
      file)
     (t
      (write-region "" nil file)
      nil))))

(defun pthai-mp3-permit-fetch(thaiword)
  "return t if downloading of mp3 files allowed based on
'pthai-audio-download setting and if word previously downloaded"
  (cond
   ((= pthai-audio-download 2)
    t)
   ((= pthai-audio-download 1)
    (not (pthai-soundfile-downloaded-p thaiword)))
   (t
    nil)))

(defun pthai-download-and-play(thaiword &optional force_dl)
  "download and play audio for a thai word. returns t if audio
played for complete word, nil otherwise"
    (unless (string-match-p "^\\ct+$" thaiword)
      (error "only thai strings supported"))
    ;; split up word, download parts, and play "best" word combo
    (let* ((splits
	    (sort (mapcar 'split-string (pthai-split-all thaiword))
		  'pthai-soundfiles-sort))
	   (dl_done
	    (progn
	      ;; when forcing downloads, try unsplit word first
	      (when force_dl
		(setq splits (cons (list thaiword) (delete (list thaiword) splits))))
	      (seq-find
	       (lambda(spl)
		 (seq-every-p
		  'identity
		  (mapcar
		   (lambda(w) (if (or force_dl (pthai-mp3-permit-fetch w))
				  (pthai-mp3-fetch w)
				(pthai-soundfile-playable-p w))) spl)))
	       splits)))
	   (play_done
	    (mapcar (lambda(x)
		      (and (not (string-match-p pthai-regexp-single-consonant x))
			   (pthai-mp3-play x)))
		    (or dl_done
			(car (sort splits 'pthai-soundfiles-sort))))))
      ;; pause if no audio played
      (when (and (> pthai-audio-missing-pause 0.0)
		 (seq-every-p 'null play_done))
	(sleep-for pthai-audio-missing-pause))
      ;; complete word played
      (seq-every-p 'identity play_done)))

(defun pthai-highlight-say-bounds(bounds &optional force_dl no_audio)
  "helper to highlight and say word at bounds, lookup and message
its definition. if option force_dl, force audio download.  if
option no_audio, don't play audio"
  (let* ((sdef pthai-audio-display-definition)
	 (p1 (car bounds))
	 (p2 (cdr bounds))
	 (word (buffer-substring-no-properties p1 p2)))
    (cond
     ;; number
     ((string-match-p pthai-regexp-any-number word)
      (pulse-momentary-highlight-region p1 p2)
      (pthai-lookup word sdef)
      (redisplay)
      (unless no_audio (pthai-say-number word)))
     ;; thai word
     ((string-match-p "^\\ct+$" word)
      (pulse-momentary-highlight-region p1 p2)
      (pthai-lookup word sdef)
      (redisplay)
      (unless no_audio (pthai-download-and-play word force_dl)))
     ;; english word
     ((string-match-p "^\\([a-zA-Z]+\\)$" word)
      (pulse-momentary-highlight-region p1 p2)
      (redisplay)
      (if no_audio
	  (pthai-lookup word sdef)
	(dolist (tmp (pthai-lookup word))
	  (pulse-momentary-highlight-region p1 p2)
	  (pthai-lookup word sdef)
	  (redisplay)
	  (pthai-download-and-play tmp force_dl)))))))

(defun pthai-say-region-helper(p1 p2 &optional force_dl thai_only)
  "helper function to play audio for a selected region. force
downloads with force_dl arg. play only thai words with thai_only
arg"
  (let ((old_cursor cursor-type)
	(m_rexp (concat "\\("
			"\\ct+\\|"
			"[0-9]+"
			(if thai_only "\\)"
			  "\\|[a-zA-Z]+\\)")))
	bounds)
    (unwind-protect
	(save-mark-and-excursion
	  (goto-char p1)
	  (deactivate-mark)
	  (setq cursor-type nil)
	  (while (re-search-forward m_rexp p2 t)
	    (setq bounds
		  (if (string-match-p "\\ct" (match-string 1))
		      (pthai-bounds-of-thaiwords-at-point (match-beginning 1))
		    (list (cons (match-beginning 1) (match-end 1)))))
	    ;; filter words that are completely out of the region, which can
	    ;; happen if thai string overlaps end points
	    (setq bounds
		  (seq-remove
		   (lambda (x) (or (>= p1 (cdr x)) (>= (car x) p2))) bounds))
	    (dolist (tmp bounds)
	      (pthai-highlight-say-bounds tmp force_dl))))
      (setq cursor-type old_cursor))))

(defun pthai-say(text &optional force_dl thai_only)
  "split up text string, download and play audio for thai
words. return definitions of words as a string.  also say arabic
or thai numbers in thai"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let (words)
    (cond
     ;; meanings of english words with spaces, e.g., "good morning"
     ((and (string-match-p "^[a-zA-Z ]+$" text)
	   (setq words (or (pthai-lookup text)
			   (pthai-classifier-lookup text))))
      (message "%s = %s" text (string-join words ", "))
      (dolist (tmp words)
	(pthai-download-and-play tmp force_dl)))
     (t
      (save-excursion
	(with-temp-buffer
	  (insert text)
	  (pthai-say-region-helper (point-min)
				   (point-max) force_dl thai_only)))))))

(defun pthai-say-region(p1 p2 &optional force_dl)
  "play thai audio for selected region, force downloads if option force_dl"
  (interactive "r\nP")
  (pthai-say-region-helper p1 p2 force_dl))

(defun pthai-say-region-thai-only(p1 p2 &optional force_dl)
  "play thai audio for thai words in highlighted region"
  (interactive "r\nP")
  (pthai-say-region-helper p1 p2 force_dl t))

(defun pthai-say-buffer(&optional force_dl)
  "play thai audio for buffer"
  (interactive "P")
  (pthai-say-region-helper (point-min) (point-max) force_dl))

(defun pthai-say-buffer-from-point(&optional force_dl)
  "play thai audio for buffer from current point"
  (interactive "P")
  (pthai-say-region-helper (point) (point-max) force_dl))

(defun pthai-say-buffer-from-point-thai-only(&optional force_dl)
  "play thai audio for buffer from current point, thai words only"
  (interactive "P")
  (pthai-say-region-helper (point) (point-max) force_dl t))

(defun pthai-say-buffer-thai-only(&optional force_dl)
  "play thai audio for thai words in buffer"
  (interactive "P")
  (pthai-say-region-helper (point-min) (point-max) force_dl t))

(defun pthai-say-line(&optional force_dl)
  "play thai audio for current line in buffer"
  (interactive "P")
  (pthai-say-region-helper (line-beginning-position)
			   (line-end-position)
			   force_dl))

(defun pthai-say-line-from-point(&optional force_dl)
  "play thai audio for current line from point in buffer"
  (interactive "P")
  (pthai-say-region-helper (point)
			   (line-end-position)
			   force_dl))

(defun pthai-say-line-thai-only(&optional force_dl)
  "play thai audio for current line in buffer, thai words only"
  (interactive "P")
  (pthai-say-region-helper (line-beginning-position)
			   (line-end-position)
			   force_dl
			   t))

(defun pthai-say-string-at-point(&optional force_dl)
  "play thai audio and display defs for a string at point in
buffer. force downloads of audio if option arg force_dl"
  (interactive "P")
  (let ((bounds (or (pthai-bounds-of-thaistring-at-point)
		    (bounds-of-thing-at-point 'word))))
    (if bounds
	(pthai-say-region (car bounds) (cdr bounds) force_dl)
      (message "string not found at point"))))

(defun pthai-say-word-at-point(&optional force_dl)
  "play thai audio and display definition for word at
point. force download of audio if option arg force_dl"
  (interactive "P")
  (let ((bounds (or (pthai-bounds-of-thaiword-at-point)
		    (bounds-of-thing-at-point 'word))))
    (if bounds
	(pthai-say-region (car bounds) (cdr bounds) force_dl)
      (message "word not found at point"))))

(defun pthai-say-number(numi)
 "say number (thai or arabic) in thai. if arabic number, first
convert to thai. returns thai number as a string.  arg numi can
be an integer or thai/string integer with commas"
 (interactive "snumber: ")
 (let* ((tnames '(("1" . "เอ็ด") ("10" . "สิบ") ("20" . "ยี่สิบ")
		  ("100" . "ร้อย") ("1000" . "พัน") ("10000" . "หมื่น")
		  ("100000" . "แสน") ("1000000" . "ล้าน")
		  ("-" . "ลบ")))
	(sym2wor (lambda(x) (or (cdr (assoc x tnames))
				(error "unknown sym %s" x))))
	;; convert to integer or error
	(num (or (and (integerp numi) numi)
		 (and (stringp numi)
		      (setq numi (replace-regexp-in-string "," "" numi))
		      (string-match-p "^-?[0-9]+$" numi)
		      (string-to-number numi))
		 (and (stringp numi)
		      (string-match-p "^-?[๐๑๒๓๔๕๖๗๘๙]+$" numi)
		      (string-to-number (pthai-number numi)))
		 (error "unknown format for %s" numi)))
	(num_t (pthai-number num))
	(rlist
	 ;; drop "-" if negative
	 (nreverse (split-string (substring num_t (if (< num 0) 1 0)) "" t)))
	(tlist
	 (flatten-tree
	  (nreverse
	   (seq-map-indexed
	    (lambda(x i)
	      (cond
	       ;; digit 1
	       ((= 0 (mod i 6))
		(list
		 (cond ((string= "๐" x)	   ;; ignore zeros except 0
			(if (= num 0) x nil))
		       ((string= "๑" x)	   ;; leading or trailing 1
			(if (= i (1- (length rlist)))
			    x (funcall sym2wor "1")))
		       (t
			x))
		 ;; need to add "million" for each 1,000,000
		 (when (> i 0)
		   (funcall sym2wor "1000000"))))
	       ;; skip zeros
	       ((string= "๐" x)
		nil)
	       ;; digit 2
	       ((= 1 (mod i 6))
		(if (string= "๒" x)
		    (funcall sym2wor "20")
		  (list (unless (string= "๑" x) x)
			(funcall sym2wor "10"))))
	       ;; digits 3-5
	       (t
		(list x
		      (funcall sym2wor (string-pad "10" (1+ (mod i 6)) ?0))))))
	    rlist)))))
   ;; restore minus sign if negative
   (when (< num 0)
     (setq tlist (cons (funcall sym2wor "-") tlist)))
   ;; play audio
   (mapc 'pthai-download-and-play tlist)
   ;; return thai number as a string
   (when (called-interactively-p 'any)
     (message num_t))
   num_t))

(defun pthai-number(&optional num)
  "convert a number between thai and arabic, and vice versa.
message user converted value if called interactively. number is
returned as a string. any non-numeric characters are returned
unmodified"
  (interactive)
  (let ((nlist (seq-mapn 'cons (split-string "0123456789" "" t) pthai-numbers)))
    (unless num
      (setq num (read-string "number: ")))
    (unless (stringp num)
      (setq num (number-to-string num)))
    (setq num
	  (mapconcat
	   (lambda(x) (or (cdr (assoc x nlist)) (car (rassoc x nlist)) x))
	   (split-string num "" t) ""))
    (if (called-interactively-p 'any)
	(message num))
    num))

(defun pthai-parse-hms(timestr &optional thaitime)
  "parse hour-minute-second strings like 7 1pm 12:32pm 22:07
9:45p.m. 0645 3.27am 15:23:55 or \"now\".  returns list of
(SEC MIN HOUR) or nil if parsing fails"
  (let ((ampm_rexp "\\([AaPp]\.?[Mm]\.?\\)$")
	am_pm second minute hour)
    ;; "now"
    (if (string-match-p "^[Nn][Oo][Ww]$" timestr)
	(setq timestr
	      (nth 3 (split-string
		      (pthai-time-now (not thaitime))))))
    ;; strip out anything that doesn't look like a time
    (setq timestr
	  (replace-regexp-in-string "[^0-9:\.pPaAmM]" "" timestr))
    ;; determine am/pm and remove from string
    (when (string-match-p ampm_rexp timestr)
      (if (string-match-p "[Aa]" timestr)
	  (setq am_pm "am")
	(setq am_pm "pm"))
      (setq timestr
	    (replace-regexp-in-string ampm_rexp "" timestr)))
    ;; parse hour/minute/seconds digits
    (cond
     ;; 1 or 2 digit, 1 11
     ((string-match "^\\([0-9]\\{1,2\\}\\)$" timestr)
      (setq hour (match-string 1 timestr)
	    minute "0"
	    second "0"))
     ;; 4 digits, 0645
     ((string-match "^\\([0-9]\\{4\\}\\)$" timestr)
      (setq hour (substring timestr 0 2)
	    minute (substring timestr 2 4)
	    second "0"))
     ;; 6 digits, 064532
     ((string-match "^\\([0-9]\\{6\\}\\)$" timestr)
      (setq hour (substring timestr 0 2)
	    minute (substring timestr 2 4)
	    second (substring timestr 4 6)))
     ;; 12:32 22:07 3.27 2:35
     ((string-match "^\\([0-9]\\{1,2\\}\\)[:\.]\\([0-9]\\{1,2\\}\\)$" timestr)
      (setq hour (match-string 1 timestr)
	    minute (match-string 2 timestr)
	    second "0"))
     ;; 12:43:54 or 12.43.54
     ((string-match
       "^\\([0-9]\\{1,2\\}\\)[:\.]\\([0-9]\\{1,2\\}\\)[:\.]\\([0-9]\\{1,2\\}\\)$" timestr)
      (setq hour (match-string 1 timestr)
	    minute (match-string 2 timestr)
	    second (match-string 3 timestr))))
    ;; convert to strings
    (if second (setq second (string-to-number second)))
    (if minute (setq minute (string-to-number minute)))
    (if hour (setq hour (string-to-number hour)))
    ;; adjust/sanity checks for am/pm
    (when (and am_pm hour)
      (cond
       ((and am_pm (> hour 12))
	(setq hour nil))
       ((and (string= am_pm "am") (= 12 hour))
	(setq hour 0))
       ((and (string= am_pm "pm") (< hour 12))
	(setq hour (+ 12 hour)))))
    ;; return nil unless all valid
    (and hour (>= hour 0) (<= hour 24)
	 minute (>= minute 0) (< minute 60)
	 second (>= second 0) (< second 60)
	 (list second minute hour))))

(defun pthai-time-loc2th(timestr &optional loctime)
"convert an hour:minute:second time string to thai time. use
\"now\" for current time.  if optional loctime, convert thai time
to local time"
  (interactive "stime: ")
  (let* ((c_time (current-time))
	 (time_format (concat "%a %b %e %H:%M:%S %Z %Y"))
	 (thai_offset (* 7 3600)) ;; UTC+7 or ICT or +07 or "Asia/Bangkok"
	 (thai_zone (current-time-zone nil thai_offset))
	 tlist enc_time loc_fts thai_fts o_str)
    ;; keep reading until valid second/minute/hour
    (while (not (setq tlist (pthai-parse-hms timestr loctime)))
      (setq timestr (read-string "time: ")))
    (if loctime
	(setq tlist (append tlist (nthcdr 3 (decode-time c_time thai_zone)))
	      enc_time (apply 'encode-time tlist)
	      loc_fts (format-time-string time_format enc_time (current-time-zone))
	      thai_fts (format-time-string time_format enc_time thai_zone)
	      o_str (concat "thai time  = " thai_fts "\n" "local time = " loc_fts))
      (setq tlist (append tlist (nthcdr 3 (decode-time c_time)))
	    enc_time (apply 'encode-time tlist)
	    loc_fts (format-time-string time_format enc_time (current-time-zone))
	    thai_fts (format-time-string time_format enc_time thai_zone)
	    o_str (concat "local time = " loc_fts "\n" "thai time  = " thai_fts)))
    (message o_str)))

(defun pthai-time-th2loc(timestr)
"convert a thai time (hour/minute/second) to local time.  use
\"now\" for current time"
  (interactive "stime: ")
  (pthai-time-loc2th timestr t))

(defun pthai-time-now(&optional loctime)
  "return thai current time.  if optional loctime, return current
local time. message user time if called interactively"
  (interactive "P")
  (let* ((c_zone (current-time-zone))
	 (t_zone (current-time-zone nil (* 7 3600)))
	 (time_format (concat "%a %b %e %H:%M:%S %Z %Y"))
	 (tstr
	  (format-time-string time_format (current-time)
			      (if loctime c_zone t_zone))))
    (when (called-interactively-p 'any)
      (message tstr))
    tstr))

(defun pthai-say-time(timestr)
  "say a time in thai, use time like 1pm, 12:45pm, 17:34.  use
\"now\" for current time. returns time string"
  (interactive "stime: ")
  (let (tlist hour minute sec mhour tmp)
    (while (not (setq tlist (pthai-parse-hms timestr)))
      (setq timestr (read-string "time: ")))
    (setq sec (nth 0 tlist)
	  minute (nth 1 tlist)
	  hour (nth 2 tlist)
	  mhour (mod hour 6))
    ;; get hour
    (cond
     ;; 12am = เที่ยงคืน or หกทุ่ม or สองยาม
     ((or (< hour 1) (and (= hour 24) (= minute 0)))
      (setq tmp (random 4))
      (cond ((< tmp 2)
	     (setq tlist (list "เที่ยงคืน")))
	    ((= tmp 2)
	     (setq tlist (list "หกทุ่ม")))
	    (t
	     (setq tlist (list "สองยาม")))))
     ;; 1am 2am 3am 4am 5am = ตี + number[1-5]
     ((< hour 6)
      (setq tlist (list "ตี" hour)))
     ;; 6am = หก โมง เช้า or ตีหก or ย่ำรุ่ง
     ((< hour 7)
      (setq tmp (random 4))
      (cond ((< tmp 2)
	     (setq tlist (list "หก" "โมง" "เช้า")))
	    ((= tmp 2)
	     (setq tlist (list "ตีหก")))
	    (t
	     (setq tlist (list "ย่ำรุ่ง")))))
     ;; 7am 8am 9am 10am 11am = 1-5  + โมง + เช้า
     ;;                       = 7-11 + โมง + (เช้า)
     ((< hour 12)
      (if (= 0 (random 2))     ;; 1-5
	  (if (= mhour 1)
	      (setq tlist (list "โมง" "เช้า"))
	    (setq tlist (list mhour "โมง" "เช้า")))
	(setq tlist (list hour "โมง"))  ;; 7-11
	(if (= 0 (random 2))
	    (setq tlist (append tlist (list "เช้า"))))))
     ;; 12pm = เที่ยงวัน
     ((< hour 13)
      (setq tlist (list "เที่ยงวัน")))
     ;; 1pm 2pm 3pm 4pm 5pm   = บ่าย + number[1-5] + โมง or
     ;;             4pm       = สี่ +  โมง + เย็น or
     ;;             5pm       = ห้า + โมง + เย็น
     ((< hour 18)
      (cond
       ((= hour 13) ;; 1pm
	(setq tlist (list "บ่าย" "โมง")))
       ((or (= hour 14) (= hour 15))
	(setq tlist (list "บ่าย" mhour "โมง")))
       ((= hour 16) ;; 4pm
	(if (= 0 (random 2))
	    (setq tlist (list "สี่" "โมง" "เย็น"))
	  (setq tlist (list "บ่าย" mhour "โมง"))))
       (t           ;; 5pm
	(if (= 0 (random 2))
	    (setq tlist (list "ห้า" "โมง" "เย็น"))
	  (setq tlist (list "บ่าย" mhour "โมง"))))))
     ;; 6pm = หก โมง เย็น or ย่ำค่ำ
     ((< hour 19)
      (if (= 0 (random 3))
	  (setq tlist (list "ย่ำค่ำ"))
	(setq tlist (list "หก" "โมง" "เย็น"))))
     ;; 7pm 8pm 9pm 10pm 11pm = number[1-5] + ทุ่ม
     ((< hour 24)
      (setq tlist (list mhour "ทุ่ม")))
     (t
      (error (concat "bad hour " hour))))
    ;; minutes are appended 10:07am = สิบ โมง (เช้า) + เจ็ด นาที
    (unless (or (= minute 0) (= hour 24))
      (setq tlist (append tlist (list minute "นาที"))))
    (when (and sec (/= sec 0))
      (setq tlist (append tlist (list sec "วินาที"))))
    ;; convert everything to strings
    (setq tlist
	  (mapcar (lambda (x) (if (numberp x) (number-to-string x) x)) tlist))
    ;; play audio
    (dolist (tmp tlist)
      (pthai-say tmp))
    (setq tmp
	  (concat (number-to-string hour) ":" (format "%02d" minute)))
    (if sec
	(setq tmp (concat tmp ":" (format "%02d" sec))))
    (message tmp)
    tmp))

(defun pthai-spell-p(thaistr)
  "return non-nil if thai string (a word or contiguous thai
words) is spelled correctly, i.e., string or strings are in dictionary"
  (if (pthai-dictionary-p thaistr)
      t
    (let* ((slen (length thaistr))
	   (dp (make-bool-vector (1+ slen) nil)))
      (unless (> slen 0)
	(error "no word to check"))
      (aset dp 0 t)
      (dolist (i (number-sequence 1 slen))
	(catch 'psc-done
	  (dotimes (j i)
	    (when (and (aref dp j)
		       (pthai-dictionary-p (substring thaistr j i)))
	      (aset dp i t)
	      (throw 'psc-done t)))))
      (aref dp slen))))

(defun pthai-spell-reducer(slist)
  "helper function to reduce spelling suggestions, 
converts '((ไม่ มี) (t t)) => ((ไม่มี) (t))"
  (if (or (< (length (nth 1 slist)) 2)
	  (< (length (seq-filter (lambda(x) (eq x t)) (nth 1 slist))) 2))
      slist
    (let ((n_spl (nth 0 slist))
	  (n_cho (nth 1 slist))
	  (i 0)
	  (f_deln
	   (lambda(n lst) (setf (nthcdr n lst) (nthcdr (1+ n) lst)) lst)))
      (while (< i (1- (length n_cho)))
	(cond
	 ((and (eq t (nth i n_cho))
	       (eq t (nth (1+ i) n_cho)))
	  (setf (nth i n_spl) (concat (nth i n_spl) (nth (1+ i) n_spl)))
	  (setq n_spl (funcall f_deln (1+ i) n_spl)
	   	n_cho (funcall f_deln i n_cho)))
	 (t
	  (setq i (1+ i)))))
      (list n_spl n_cho))))

(defun pthai-spell-suggest(word)
  "return a list of suggested thai spellings for a thai word
based on levenshtein distance.  return nil if word is contained
in dictionary or no suggestions are available"
  (if (pthai-dictionary-p word)
      nil
    (let ((words (hash-table-keys pthai-dictionary))
	  (passes 4)
	  rval)
      (catch 'done
	(dolist (i (number-sequence 1 passes))
	  (dolist (elem words)
	    (if (and (<= (string-distance word elem) i)
		     (not (string-match-p pthai-regexp-any-number elem)))
		(push elem rval)))
	  (and rval
	       (throw 'done (sort rval 'string-lessp))))))))

(defun pthai-spell-merge-singles(slist)
  "reduce single char splits trying to get better spell
suggestions, for example convert 'เพื่อ อ ไร' => 'เพื่อ อไร'"
  (if (or (< (length slist) 2)
	  (eq nil (seq-some (lambda(x) (= 1 (length x))) slist)))
      slist
    (let ((rlist slist)
	  (i 0)
	  (f_deln
	   (lambda(n lst) (setf (nthcdr n lst) (nthcdr (1+ n) lst)) lst))
	  a b)
      (while (< i (length rlist))
	(if (or (> (length (nth i rlist)) 1)
		(pthai-dictionary-p (nth i rlist))
		(string-match-p pthai-regexp-valid-standalone-char (nth i rlist)))
	    (setq i (1+ i))
	  (cond
	   ((= i 0)
	    (setq a i b (1+ i)))
	   ((= i (1- (length rlist)))
	    (setq a (1- i) b i))
	   ((not (pthai-dictionary-p (nth (1- i) rlist)))
	    (setq a (1- i) b i))
	   (t ;;(not (pthai-dictionary-p (nth i rlist)))
	    (setq a i b (1+ i))))
	  (setf (nth a rlist) (concat (nth a rlist) (nth b rlist)))
	  (setq	rlist (funcall f_deln b rlist))))
      rlist)))

(defun pthai-spell-suggestions(thaistr &optional no_split)
  "split thai word/string and return list of word splits/and
spelling suggestions from dictionary where subwords are t if in
dictionary, list of word spelling suggestions, or nil of no/too
many suggestions, e.g.,
 เตีอง => '(((เ ตี อง) (nil t t))
          ((เตี อง)  ((ตี สตี เต เตก เตช เตย เตะ เตา เลตี) t))
          ((เตีอง)   ((เตียง))))
option no_split prevents word splitting for suggestions with a
single thai word"
  (let ((slist
	 (if no_split (list (list thaistr))
	   (mapcar 'split-string (pthai-split-all thaistr))))
	sug_list)
    ;; merge invalid stand alone characters for fewer/better suggestions
    (setq slist (delete-dups (mapcar 'pthai-spell-merge-singles slist)))
    ;; get spelling suggestions
    (setq sug_list (mapcar
		    (lambda(x)
		      (mapcar
		       (lambda(y) (or (pthai-spell-p y)
				      (pthai-spell-suggest y)))
		       x))
		    slist))
    ;; map splitting to suggestions
    (setq slist (seq-map-indexed
		 (lambda(elt idx) (list elt (nth idx sug_list)))
		 slist))
    ;; merge valid consecutive spellings to reduce number of choices
    (setq slist (delete-dups (mapcar 'pthai-spell-reducer slist)))
    ;; filter out splits with invalid selections if other choices are available
    (or (seq-filter (lambda(x) (if (memq nil (nth 1 x)) nil t)) slist)
	slist)))

;; xxx sample misspellings, เพราะพรุ่งนี้ได้หยุดเสาร์ทิตย์ บ้านพี่สกลหนาวเเล้ว เตีอง เพื่ออไร
(defun pthai-spell-sort(slist)
  "sort array of split thai text strings by misspellings and
'best' suggestions. car of list should be \"best\" suggestion.
slist is a list of
	((split-words) (list-of-suggestions/split-word)))."
  (sort slist
	(lambda (a b)
	  (let* ((a_sug (nth 1 a))
		 (b_sug (nth 1 b))
		 (fu_all_t (lambda(x) (seq-every-p (lambda(y) (eq y t)) x)))
		 (a_t (funcall fu_all_t a_sug))
		 (b_t (funcall fu_all_t b_sug))
		 (a_all_sug (seq-every-p 'listp a_sug))
		 (b_all_sug (seq-every-p 'listp b_sug))
		 (a_has_nil (memq nil a_sug))
		 (b_has_nil (memq nil b_sug))
		 (a_t_or_sug (not a_has_nil))
		 (b_t_or_sug (not b_has_nil))
		 (fu_words_per_sug
		  (lambda(x) (/ (float (apply '+ (mapcar 'length (seq-filter 'listp x))))
				(max 1.0 (float (length (seq-filter 'listp x)))))))
		 (a_words_per_sug (funcall fu_words_per_sug a_sug))
		 (b_words_per_sug (funcall fu_words_per_sug b_sug))
		 (fu_avelen_per_sug_word
		  (lambda(x)
		    (/ (float (apply '+ (mapcar (lambda(y) (apply '+ y))
						(mapcar (lambda (z) (mapcar 'length z))
							(seq-filter 'listp x)))))
		       (max 1.0 (float (apply '+(mapcar 'length (seq-filter 'listp x))))))))
		 (a_avelen_per_sug_word (funcall fu_avelen_per_sug_word a_sug))
		 (b_avelen_per_sug_word (funcall fu_avelen_per_sug_word b_sug))
		 )
	    (cond
	     ;; a found for all, b not
	     ((and a_t (not b_t))
	      t)
	     ;; b found for all, a not
	     ((and (not a_t) b_t)
	      nil)
	     ;; a has no nils, b has
	     ((and (not a_has_nil) b_has_nil)
	      t)
	     ;; b has no nils, a has
	     ((and (not b_has_nil) a_has_nil)
	      t)
	     ;; a 1 word with a few sug
	     ((and a_all_sug
		   (> 3 (length (car a_sug)))
		   (= 1 (length (nth 0 a))))
	      t)
	     ;; b 1 word with a few sug
	     ((and b_all_sug
		   (> 3 (length (car b_sug)))
		   (= 1 (length (nth 0 b))))
	      nil)
	     ;; a has sug & t, b just sug
	     ((and a_t_or_sug b_all_sug)
	      t)
	     ;; b has sug & t, a only sug
	     ((and b_t_or_sug a_all_sug)
	      nil)
	     ;; length a < b
	     ((< (length a_sug) (length b_sug))
	      t)
	     ;; length b < a
	     ((< (length b_sug) (length a_sug))
	      nil)
	     ;; a ave size per word suggestion > b / vice versa
	     ((and (> a_avelen_per_sug_word 0.0)
		   (> b_avelen_per_sug_word 0.0))
	      (> a_avelen_per_sug_word b_avelen_per_sug_word))
	     ;; a has fewer words per suggestions / vice versa
	     ((and (> a_words_per_sug 0.0)
		   (> b_words_per_sug 0.0))
	      (< a_words_per_sug b_words_per_sug))
	     ;; default
	     (t
	      t))))))

(defun pthai-spell-region(p1 p2 &optional no_split)
  "spell thai words in region p1 p2, if option no_split, assume
spelling a single word with no word splitting done. (can use
C-u/page-up and C-v/page-down to scroll in the help window)"
  (interactive "r\nP")
  (let* ((case-fold-search nil)
	 (n_pt (point))
	 (p_reporter
	  (make-progress-reporter "Checking..." p1 p2 nil 5 0.5))
	 (s_choice
	  (list '(?\ "skip" "skip word")
		'(?r "replace word" "replace word")
		'(?R "Replace str" "replace string")
		'(?a "accept word" "accept word for session")
		'(?A "Accept str" "accept string for session")
		'(?d "dict reread" "reread dictionary")
		'(?D "Dict reset" "clear and reread dictionary")
		'(?x "exit" "exit and move cursor to original point")
		'(?X "eXit at point" "exit and leave cursor at current point")))
	 (split_key ?c)
	 (key_choice
	  (seq-difference
	   (mapcar 'string-to-char
		   (append (mapcar 'number-to-string (number-sequence 0 9))
			   (mapcar 'char-to-string (number-sequence ?a ?z))
			   (mapcar 'char-to-string (number-sequence ?A ?Z))))
	   (cons split_key (mapcar 'car s_choice))))
	 s_bound s_str
	 s_all s_idx s_idx_change
	 (f_splits (lambda() (car (nth s_idx s_all))))
	 (f_choices (lambda(&optional x) (cadr (nth (or x s_idx) s_all))))
	 w_bound w_choice t_bound
	 ch_select l_select
	 c_done c_quit tmp)
    (goto-char p1)
    (while (and (< (point) p2)
		(not c_quit)
		(re-search-forward pthai-regexp-spelling-chars p2 t)
		(setq s_bound (cons (match-beginning 1) (match-end 1))
		      s_str (buffer-substring-no-properties (car s_bound)
							    (cdr s_bound))))
      (goto-char (cdr s_bound))
      (progress-reporter-update p_reporter (point))
      (unless (pthai-spell-p s_str)
	(setq c_done nil
	      s_idx 0
	      s_all (pthai-spell-sort (pthai-spell-suggestions s_str no_split)))
	;; double check a solution was not really found with different
	;; word-splitting, should be first choice
	(unless (seq-every-p (lambda(x) (eq x t)) (funcall f_choices 0))
	  (while (not c_done)
	    ;; update indices for choices if needed
	    (if s_idx_change
		(setq s_idx_change nil
		      s_idx (% (1+ s_idx) (length s_all))))
	    ;; find bounds of first misspelled word
	    (if (not s_all)
		(setq w_bound s_bound w_choice nil)
	      (setq w_bound nil w_choice nil)
	      (dotimes (i (length (funcall f_splits)))
		(when (and (not w_bound)
			   (not (eq t (nth i (funcall f_choices)))))
		  (setq w_choice (nth i (funcall f_choices)))
		  (setq tmp
			(pthai-bounds-offsets (car s_bound) (funcall f_splits)))
		  (setq w_bound (nth i tmp)))))
	    (unless w_bound
	      (error "could not determine bounds for region"))
	    ;; map word selections to available key choices, put definitions in help menu
	    (setq tmp nil)
	    (dotimes (i (min (length w_choice) (length key_choice)))
	      (setq tmp (cons (list (nth i key_choice)
				    (nth i w_choice)
				    (string-join (pthai-lookup (nth i w_choice)) ", "))
			      tmp)))
	    (setq w_choice (reverse tmp))
	    ;; add menu option to resplit if more than 1 splits
	    (setq tmp (and (> (length s_all) 1)
			   (list
			    (list split_key
				  (concat "cycle-splits(" (number-to-string (1+ s_idx)) "/"
					  (number-to-string (length s_all)) ")")
				  "view other splits"))))
	    ;; goto end of misspelled word, highlight word, and prompt user
	    (goto-char (cdr w_bound))
	    (pthai-highlight-on (car w_bound) (cdr w_bound))
	    (unwind-protect
		(setq l_select (read-multiple-choice "" (append w_choice s_choice tmp)))
	      (pthai-highlight-off (car w_bound) (cdr w_bound)))
	    (setq ch_select (car l_select))
	    (setq c_done
		  (cond
		   ;; leave word as is and goto next
		   ((char-equal ?\  ch_select)
		    t)
		   ;; menu choice to replace word
		   ((memq ch_select (mapcar 'car w_choice))
		    (delete-region (car w_bound) (cdr w_bound))
		    (setq tmp (cadr l_select))
		    (insert tmp)
		    (goto-char (- (point) (length tmp)))
		    (setq p2 (+ p2 (- (- (cdr w_bound) (car w_bound))) (length tmp)))
		    (if (< (point) n_pt)
			(setq n_pt (+ n_pt (- (- (cdr w_bound) (car w_bound))) (length tmp))))
		    t)
		   ;; replace word or string manually
		   ((or (char-equal ?r ch_select) (char-equal ?R ch_select))
		    (setq t_bound
			  (if (char-equal ?r ch_select) w_bound s_bound))
		    (goto-char (cdr t_bound))
		    (pthai-highlight-on (car t_bound) (cdr t_bound))
		    (unwind-protect
			(setq tmp (pthai-completing-read-dynamic
				   "replacement for: "
				   (buffer-substring-no-properties (car t_bound)
								   (cdr t_bound))))
		      (pthai-highlight-off (car t_bound) (cdr t_bound)))
		    (delete-region (car t_bound) (cdr t_bound))
		    (insert tmp)
		    (goto-char (- (point) (length tmp)))
		    (setq p2 (+ p2 (- (- (cdr t_bound) (car t_bound))) (length tmp)))
		    (if (< (point) n_pt)
			(setq n_pt (+ n_pt (- (- (cdr t_bound) (car t_bound))) (length tmp))))
		    t)
		   ;; save word or string for session
		   ((or (char-equal ?a ch_select) (char-equal ?A ch_select))
		    (setq t_bound
			  (if (char-equal ?a ch_select) w_bound s_bound))
		    (setq tmp (buffer-substring-no-properties (car t_bound) (cdr t_bound)))
		    (pthai-highlight-off (car t_bound) (cdr t_bound))
		    (goto-char (cdr t_bound))
		    (pthai-dictionary-add-word tmp)
		    t)
		   ;; regenerate/reset dictionary, goto beginning of current string
		   ((or (char-equal ?d ch_select) (char-equal ?D ch_select))
		    (message nil)
		    (redisplay)
		    (pthai-dictionary-read-files (char-equal ?D ch_select))
		    (goto-char (car s_bound))
		    t)
		   ;; use other word splittings
		   ((char-equal split_key ch_select)
		    (setq s_idx_change t)
		    (pthai-highlight-off (car s_bound) (cdr s_bound))
		    (goto-char (car s_bound))
		    nil)
		   ;; exit, restore cursor position
		   ((char-equal ?x ch_select)
		    (pthai-highlight-off (car s_bound) (cdr s_bound))
		    (setq c_quit t))
		   ;; exit, leave cursor at point
		   ((char-equal ?X ch_select)
		    (pthai-highlight-off (car s_bound) (cdr s_bound))
		    (setq n_pt (car w_bound)
			  c_quit t))
		   ;; invalid selection
		   (t
		    nil)))
	    (message nil)
	    (redisplay)))))
    (progress-reporter-done p_reporter)
    (goto-char n_pt)
    (if c_quit
	(message "exit."))))

(defun pthai-spell-buffer()
  "spell check thai words in buffer"
  (interactive)
  (pthai-spell-region (point-min) (point-max)))

(defun pthai-spell-buffer-from-point()
  "spell check thai words in from point to end of buffer"
  (interactive)
  (pthai-spell-region (point) (point-max)))

(defun pthai-spell-line()
  "spell check thai words on current line"
  (interactive)
  (pthai-spell-region (line-beginning-position) (line-end-position)))

(defun pthai-spell-string-at-point(&optional no_split)
  "spell check one or more contiguous thai words (a string) at point.
return nil if point not at thai string.  with option no_split,
don't do word splitting on string"
  (interactive)
  (let ((bounds (pthai-bounds-of-thaistring-at-point)))
    (when bounds
      (pthai-spell-region (car bounds) (cdr bounds) no_split))))

(defun pthai-spell-word-at-point()
  "spell check thai string as a single word at point without any
word splitting. return nil if point not at thai string"
  (interactive)
  (pthai-spell-string-at-point t))

(defun pthai-complete-word()
  "try to complete and insert thai word at point, similar to
'ispell-complete-word.  return selected word or nil"
  (interactive)
  (let* ((bound (pthai-bounds-of-thaistring-at-point))
	 (word (and bound
		    (buffer-substring-no-properties (car bound)
						    (cdr bound))))
	 (selw (pthai-completing-read-dynamic "word: " word)))
    (when (> (length selw) 0)
      (if bound
	  (delete-region (car bound) (cdr bound)))
      (insert selw)
      selw)))

(defun pthai-dictionary-parse-filelist(files)
  "take a list of files and directories, returns list of all regular
files. input directories are traversed recursively. files and
directories without valid paths or permissions are ignored"
  (let* ((dfiles
	  (flatten-tree
	   (mapcar
	    (lambda(x) (and (file-directory-p x)
			    (file-readable-p x)
			    (file-executable-p x)
			    (directory-files-recursively x "" nil t)))
	    files)))
	 (rfiles
	  (append files dfiles)))
    (setq rfiles (seq-filter 'file-regular-p rfiles)
	  rfiles (seq-filter 'file-readable-p rfiles)
	  rfiles (sort (delete-dups rfiles) 'string-lessp))))

(defun pthai-dictionary-add-word(thaiword &optional def eng_class th_class)
  "update 'pthai-dictionary with new entry or merge with existing
entry. thaiword is a thai string, other args can be nil, a
string, or a list of strings. returns merged definition of word
that was inserted.  also adds word to 'thai-word-table"
  (interactive "sthai word to add: ")
  (unless (string-match-p "^\\ct+$" thaiword)
    (error "adding non-thai word %s to dictionary" thaiword))
  (let ((olist
	 (gethash thaiword pthai-dictionary))
	(nlist
	 (mapcar (lambda(x) (if (listp x) x (list x)))
		 (list def eng_class th_class))))
    (when olist
      (setq nlist (seq-mapn #'append nlist olist)))
    (setq nlist
	  (mapcar (lambda(x) (sort (delq nil (delete-dups x)) 'string-lessp))
		  nlist))
    (pthai-twt-add thaiword)
    (puthash thaiword nlist pthai-dictionary)))

(defun pthai-dictionary-remove-word(thaiword)
  "remove word from dictionary and 'thai-word-table, return t if
word in dictionary, nil otherwise"
  (interactive "sthai word to remove: ")
  (pthai-twt-remove thaiword)
  (when (gethash thaiword pthai-dictionary)
    (remhash thaiword pthai-dictionary)
    t))

(defun pthai-dictionary-override-word(thaiword &optional def eng_class th_class)
  "update 'pthai-dictionary and 'thai-word-table by clearing any
existing defintion of thaiword, and creating new entry with args.
uses the same args as 'pthai-dictionary-add-word"
  (interactive "sthai word to override: ")
  (pthai-dictionary-remove-word thaiword)
  (pthai-dictionary-add-word thaiword def eng_class th_class))

(defun pthai-dictionary-find-regexp(regexp)
  "display list of thai words from 'pthai-dictionary matching thai regexp"
  (interactive "xregular expression: ")
  (let ((bname "*pthai-dictionary-find-regexp*")
	(count 0))
    ;; convert symbol to string so expression won't get re-quoted when
    ;; running interactively
    (unless (stringp regexp)
      (setq regexp (symbol-name regexp)))
    (set-buffer (get-buffer-create bname))
    (erase-buffer)
    (maphash
     (lambda(k v) (when (string-match-p regexp k)
		    (insert k "\n")
		    (setq count (1+ count))))
     pthai-dictionary)
    (cond
     ((= count 0)
      (kill-buffer bname)
      (message "no matches found for %s" regexp))
     (t
      (sort-lines nil (point-min) (point-max))
      (switch-to-buffer bname)
      (goto-char (point-min))
      (message "%d matches founds" count)))))

(defun pthai-dictionary-find-word(&optional word)
  "for any complete word, find dictionary files names and line
numbers containing word, output to a grep-mode buffer"
  (interactive)
  (let* ((bufname "*pthai-dictionary-find-word*")
	 (coding-system-for-read 'utf-8)
	 (coding-system-for-write 'utf-8)
	 (buffer-file-coding-system 'utf-8)
	 (files
	  (pthai-dictionary-parse-filelist pthai-dictionary-list))
	 (bounds
	  (or (pthai-bounds-of-thaiword-at-point)
	      (bounds-of-thing-at-point 'word)))
	 (bword
	  (and bounds
	       (buffer-substring-no-properties (car bounds) (cdr bounds))))
	 buf rlist)
    (while (< (length word) 1)
      (setq word (pthai-completing-read-dynamic "word: " bword)))
    (dolist (tmp files)
      (with-temp-buffer
	(insert-file-contents tmp)
	(goto-char (point-min))
	(while (re-search-forward (concat "\\b" word "\\b") nil t)
	  (setq rlist
		(cons (list tmp
			    (line-number-at-pos)
			    (buffer-substring (line-beginning-position)
					      (line-end-position)))
		      rlist)))))
    (if (not rlist)
	(message "no files found containing word \"%s\"" word)
      (setq rlist
	    (sort (seq-uniq rlist)
		  (lambda(a b)
		    (if (string= (car a) (car b))
			(< (cadr a) (cadr b))
		      (string< (car a) (car b))))))
      (setq rlist
	    (mapcar
	     (lambda(x)
	       (concat (car x) ":" (number-to-string (cadr x)) ":" (caddr x)))
	     rlist))
      (setq buf (get-buffer-create bufname))
      (switch-to-buffer buf)
      (read-only-mode 0)
      (erase-buffer)
      (insert "pthai-dictionary-find-word for " word "\n\n"
	      (string-join rlist "\n") "\n")
      (goto-char (point-min))
      (grep-mode))))

(defun pthai-dictionary-parse-classifiers(str)
  "helper function to parse definition line and return list like
((a b c) (ก ด ห))"
  (let ((pos 0)
	eclass tclass tmp)
    (while (string-match "\\({.*?}\\)" str pos)
      (setq tmp (match-string 1 str)
	    pos (match-end 1))
      (if (string-match-p "\\ct+" tmp)
	  (setq tclass (append (split-string tmp " *[{} \t]+ *" t) tclass))
	(setq eclass (append (split-string tmp " *[{},\t]+ *" t) eclass))))
    (list eclass tclass)))

(defun pthai-dictionary-read-region(r1 r2)
  "parse selected region into pthai-dictionary. returns number of
words read.  assumes file is in utf-8 format and lines contain a
single thai word at beginning of the line, or a thai \"definition\" line"
  (interactive "r")
  (let* ((coding-system-for-read 'utf-8)
	 (buffer-file-coding-system 'utf-8)
	 (cbuf (current-buffer))
	 (count 0)
	 tword def_line class_info)
    (with-temp-buffer
      (insert-buffer-substring-no-properties cbuf r1 r2)
      ;; remove # comments
      (goto-char (point-min))
      (while (re-search-forward "#.*$" nil t)
	(replace-match ""))
      ;; remove stuff in parentheses
      (goto-char (point-min))
      (while (re-search-forward "[ \t]*(.*?)[ \t]*" nil t)
	(replace-match " "))
      ;; consolidate spaces
      (goto-char (point-min))
      (while (re-search-forward "[ \t]+" nil t)
	(replace-match " "))
      ;; remove double quote marks
      (goto-char (point-min))
      (while (re-search-forward "\"" nil t)
	(replace-match ""))
      ;; convert all to lowercase
      (downcase-region (point-min) (point-max))
      ;; read words without definitions
      (goto-char (point-min))
      (while (re-search-forward "^\\(\\ct+\\) ?$" nil t)
	(setq count (1+ count))
	(pthai-dictionary-add-word (match-string 1)))
      ;; read words with "dictionary" format
      (goto-char (point-min))
      (while (re-search-forward "^\\(\\ct+\\) \\(.*?\\) *$" nil t)
       	(when (and (setq tword (match-string 1))
       		   (setq def_line (match-string 2))
       		   (> (length def_line) 0))
	  (cond
	   ;; parse classifier info and strip from definition
	   ((string-match-p "[{}]" def_line)
	    (setq class_info (pthai-dictionary-parse-classifiers def_line))
	    (setq def_line (replace-regexp-in-string " ?{.*?} ?" "" def_line)))
	   (t
	    (setq class_info nil)))
	  ;; save thai word, definition, classifier info	  
	  (pthai-dictionary-add-word tword
				     (split-string def_line " ?, ?" t)
				     (nth 0 class_info)
				     (nth 1 class_info))
	  (setq count (1+ count)))))
    (message "%d words read" count)
    count))

(defun pthai-dictionary-read-line()
  "parse current line into pthai-dictionary. returns number of
words read"
  (interactive)
  (pthai-dictionary-read-region (line-beginning-position)
				(line-end-position)))

(defun pthai-dictionary-read-buffer()
  "parse current buffer into pthai-dictionary. returns number of
words read"
  (interactive)
  (pthai-dictionary-read-region (point-min) (point-max)))

(defun pthai-dictionary-read-file(fname)
  "parse file into pthai-dictionary. see
'pthai-dictionary-read-region for arg info. returns number of
words read"
  (interactive "fpath to dictionary name: \n")
  (let ((coding-system-for-read 'utf-8)
	(buffer-file-coding-system 'utf-8)
	(count 0))
    (setq fname (expand-file-name fname))
    (with-temp-buffer
      (insert-file-contents fname)
      ;; log filename and word count on same line in *Messages*
      (let ((message-log-max nil))
	(setq count (pthai-dictionary-read-buffer)))
      (message "%d words in %s" count fname)
      count)))

(defun pthai-dictionary-clear()
  "clear dictionary entries in 'thai-word-table and 'pthai-dictionary"
  (interactive)
  (pthai-twt-clear)
  (clrhash pthai-dictionary))

(defun pthai-dictionary-read-files(&optional clear)
  "read all dictionary files from 'pthai-dictionary-list.  returns
total number of words in 'pthai-dictionary"
  (interactive "P")
  (let (count)
    (when clear
      (pthai-dictionary-clear))
    (mapc 'pthai-dictionary-read-file
	  (pthai-dictionary-parse-filelist pthai-dictionary-list))
    (setq count (hash-table-count pthai-dictionary))
    (message "%d unique words in pthai-dictionary" count)
    count))

(defcustom pthai-dictionary-list
  (list "/usr/pkg/share/split-thai/words"
   	"/usr/pkg/share/split-thai/sampledict.txt")
  "list of files and directories that contain thai dictionary files"
  :group 'pthai
  :set (lambda (sym val)
	 ;; don't include user word customizations during setup
	 (unless pthai-bootstrap
	   (set-default sym val)
	   (pthai-dictionary-read-files)))
  :type '(repeat file))

(defun pthai-dictionary-save(filename &optional nodefs)
  "save dictionary words and definitions to filename. if option
nodefs, save the all words but without definitions"
  (interactive "FName of file to save to: \nP")
  (let ((coding-system-for-read 'utf-8)
	(coding-system-for-write 'utf-8)
	(buffer-file-coding-system 'utf-8)
	(words (sort (hash-table-keys pthai-dictionary) 'string-lessp))
	tlist)
    (with-temp-buffer
      (dolist (tmp words)
	(if (or nodefs (pthai-dictionary-undef-p tmp))
	    (insert tmp "\n")
	  (insert tmp)
	  (setq tlist (gethash tmp pthai-dictionary))
	  (when (nth 0 tlist)
	    (insert " " (string-join (nth 0 tlist) ", ") ","))
	  (when (nth 1 tlist)
	    (insert " {" (string-join (nth 1 tlist) ", ") "},"))
	  (when (nth 2 tlist)
	    (insert " {" (string-join (nth 2 tlist) " ") "},"))
 	  ;; delete trailing ","
	  (delete-backward-char 1)
	  (insert "\n")))
      (write-region nil nil filename)
      (length words))))

(defun pthai-dictionary-p(thaiword)
  "return non-nil if thaiword in dictionary, with or without any
definition.  consider all thai numbers as being in dictionary,
mainly for word splitting purposes "
  (and (or (gethash thaiword pthai-dictionary)
	   (string-match-p pthai-regexp-thai-number thaiword))
       t))

(defun pthai-dictionary-any-p(thaiword)
  "return non-nil if thaiword in dictionary with any definition or
classifier info, or is a thai number"
  (and (or (seq-some 'identity (gethash thaiword pthai-dictionary))
	   (string-match-p pthai-regexp-thai-number thaiword))
       t))

(defun pthai-dictionary-def-p(thaiword)
  "return non-nil if thaiword has a definition, not including
classifier info, or is a thai number"
  (and (or (car (gethash thaiword pthai-dictionary))
	   (string-match-p pthai-regexp-thai-number thaiword))
       t))

(defun pthai-dictionary-undef-p(thaiword)
  "return non-nil if thaiword is in dictionary and is without any
definition or classifier info"
  (let ((tmp (gethash thaiword pthai-dictionary)))
    (and tmp
	 (seq-every-p 'null tmp)
	 t)))

(defun pthai-classifier-p(thaiword)
  "return non-nil if thaiword is a classifier in dictionary"
  (and (seq-some 'identity (cdr (gethash thaiword pthai-dictionary)))
       t))

(defun pthai-dictionary-word-count()
  "return number of words in dictionary.  message user when
called interactively, displaying counts of words with and without
definitions, classifiers, and 'thai-word-table"
  (interactive)
  (let ((defined 0)
	(undefined 0)
	(classifiers 0)
	(total (hash-table-count pthai-dictionary)))
    (maphash
     (lambda(k v) (if (pthai-dictionary-undef-p k)
		      (setq undefined (1+ undefined))
		    (setq defined (1+ defined))
		    (if (pthai-classifier-p k)
			(setq classifiers (1+ classifiers)))))
     pthai-dictionary)
    (when (or (called-interactively-p 'any) load-in-progress)
      (message
       "defined/undefined/classifiers/thai-word-table/total = %d/%d/%d/%d/%d"
       defined undefined classifiers (pthai-twt-count) total))
    total))

(defun pthai-dictionary-word-counts()
  "display word counts by starting letter from pthai-dictionary in a buffer"
  (interactive)
  (pthai-counts-by-letter
   "*pthai-dictionary-word-counts*" (hash-table-keys pthai-dictionary)))

(defun pthai-lookup-reverse(word ltype)
  "function for reverse look ups:
 word                 ltype      returns (list of ... or nil if not found)       example
 ====                 =====      ================                                =======
 english(/thai)       'def      thai definitions                                dog => (สุนัข หมา)
 english classifier   'class    thai words that are classified by english word  laws => (กฎ)
 thai classifier      'class    thai words that are classified by thai word     วงกบ => (กรอบ วง)"
  (let* ((ltypes (list 'def 'class))
	 (n (or (seq-position ltypes ltype) (error "invalid ltype: %s" ltype)))
	 rlist)
    (setq word (downcase word))
    (and (= n 1)
	 (string-match-p "^\\ct+$" word)
	 (setq n 2))
    (maphash
     (lambda(key val) (if (member word (nth n val))
			  (setq rlist (cons key rlist))))
       pthai-dictionary)
    (sort (delete-dups rlist) 'string-lessp)))

(defun pthai-lookup(word &optional force)
  "look up definition of thai or english word or phrase. returns
list of english or thai words, or nil if none found.  if called
interactively or forced, message user definition, '*' if word in
dictionary but not defined, or '?' if word not found in dictionary"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let ((wlist (or (car (gethash word pthai-dictionary))
		   (pthai-lookup-reverse (setq word (downcase word)) 'def)
		   (and (string-match-p pthai-regexp-any-number word)
			(list (pthai-number word))))))
    (when (or force (called-interactively-p 'any))
      (message "%s = %s" word
	       (cond
		(wlist
		 (string-join wlist ", "))
		((gethash word pthai-dictionary)
		 "*")
		(t
		 "?"))))
    wlist))

(defun pthai-lookup-at-point()
  "look up word definition at point"
  (interactive)
  (let ((bounds (or (pthai-bounds-of-thaiword-at-point)
		    (bounds-of-thing-at-point 'word))))
    (if bounds
	(pthai-highlight-say-bounds bounds nil t)
      (message "word not found at point"))))

(defun pthai-lookup-and-insert(word)
  "look up a word (english or thai) and insert meaning at point.
if more than one definition found, select from choices found"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let* ((tlist (pthai-lookup word))
	 (tlen (length tlist)))
    (cond
     ((= 0 tlen)
      (message "%s not found in dictionary" word))
     ((= 1 tlen)
      (insert (car tlist)))
     (t
      (insert (pthai-completing-read-full "meanings: " tlist))))))

(defun pthai-classifier-list()
  "return list of classifiers. message user if called interactively"
  (interactive)
  (let ((classifiers
	 (sort
	  (seq-filter 'pthai-classifier-p (hash-table-keys pthai-dictionary))
	  'string-lessp)))
    (when (called-interactively-p 'any)
      (if classifiers
	  (pthai-message "*pthai-classifier-list*" (string-join classifiers "\n"))
	(message "no classifiers found")))
    classifiers))

(defun pthai-classifier-count()
  "return count of classifiers in dictionary. message user if
 called interactively"
  (interactive)
  (let ((count (length (pthai-classifier-list))))
    (when (called-interactively-p 'any)
      (message "%d classifiers found in dictionary" count))
    count))

(defun pthai-classifier-lookup(thaiword &optional force)
  "return classifier information for thaiword which is a
pthai-dictionary entry (see for more info).  returns nil if thaiword
not in dictionary or not a known classifier.  message user if
called interactively or optional force"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let* ((clabel "classifier for: ")
	 (cinfo
	  (and (pthai-classifier-p thaiword)
	       (gethash thaiword pthai-dictionary)))
	 (cstr
	  (and cinfo
	       (string-trim-right
		(concat
		 (if (nth 0 cinfo)
		     (concat thaiword " = " (string-join (nth 0 cinfo) ", ") "\n")
		   (concat thaiword " is a classifier\n"))
		 (when (nth 1 cinfo)
		   (concat clabel (string-join (nth 1 cinfo) ", ") "\n"))
		 (when (nth 2 cinfo)
		   (concat clabel (string-join (nth 2 cinfo) " ") "\n")))))))
    (when (or (called-interactively-p 'any) force)
      (if cstr
	  (pthai-message (concat "*pthai-classifier-lookup-" thaiword "*") cstr)
	(message "classifier info not found for %s" thaiword)))
    cinfo))

(defun pthai-classifier-lookup-at-point()
  "look up classifier word definition at point"
  (interactive)
  (let* ((bounds (or (pthai-bounds-of-thaiword-at-point)
		     (bounds-of-thing-at-point 'word)))
	 (word (and bounds
		    (buffer-substring-no-properties (car bounds)
						    (cdr bounds)))))
    (if (and word (pthai-classifier-p word))
	(progn (pthai-highlight-say-bounds bounds nil t)
	       (message "")
	       (pthai-classifier-lookup word t))
      (message "classifier not found at point"))))

(defun pthai-classifier-by(thaiword &optional force)
  "return list of words classified by thaiword, or nil if none
found.  message user if called interactively or option force"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let ((words
	 (sort (delete-dups
		(flatten-tree (cdr (gethash thaiword pthai-dictionary))))
	       'string-lessp)))
    (when (or (called-interactively-p 'any) force)
      (if words
	  (pthai-message (concat "*pthai-classifier-by-" thaiword "*")
			 (string-join words ", "))
	(message "no words found classified by %s" thaiword)))
    words))

(defun pthai-classifier-for(word &optional force)
  "return list of words that classify word, or nil if none
found.  message user if called interactively or option force"
  (interactive (list (pthai-completing-read-dynamic "word: " nil)))
  (let ((words (sort (delete-dups
		      (pthai-lookup-reverse (downcase word) 'class))
		     'string-lessp)))
    (when (or (called-interactively-p 'any) force)
      (if words
	  (pthai-message (concat "*pthai-classifier-for-" word "*")
			 (string-join words ", "))
	(message "no classifier words found for %s" word)))
    words))

(defun pthai-year-greg2bud(&optional year)
  "convert gregorian year (A.D.) to buddhist year (B.E.). message
user if called interactively"
  (interactive)
  (let (bud_year
	(cur_year (string-to-number (format-time-string "%Y"))))
    (unless (integerp year)
      (setq year (round (read-number "year: " cur_year))))
    (setq bud_year (+ 543 year))
    (when (called-interactively-p 'any)
      (if (>= year 0)
	  (message "%d A.D. = B.E. %d" year bud_year)
	(message "%d B.C. = B.E. %d" (* -1 year) bud_year)))
    bud_year))

(defun pthai-year-bud2greg(&optional year)
  "convert buddhist year (B.E.) to gregorian year (A.D./B.C.).
message user if called interactively"
  (interactive)
  (let (greg_year
	(cur_year (+ 543
		     (string-to-number (format-time-string "%Y")))))
    (unless (integerp year)
      (setq year (round (read-number "year: " cur_year))))
    (setq greg_year (- year 543))
    (when (called-interactively-p 'any)
      (if (>= greg_year 0)
	  (message "B.E. %d = %d A.D." year greg_year)
	(message "B.E. %d = %d B.C. " year (* -1 greg_year))))
    greg_year))

(defun pthai-show-consonants()
  "display list of thai consonants in alphabetical order"
  (interactive)
  (message (string-join (car pthai-consonants) " ")))

(defun pthai-lookup-consonant-class(consonant)
  "return class (l m h) of consonant or nil if consonant not found"
  (interactive "sEnter thai consonant: ")
  (let* ((pos (seq-position (car pthai-consonants) consonant))
	 (class (and pos
		     (nth pos (cadr pthai-consonants)))))
    (when (called-interactively-p 'any)
      (if class
	  (message "%s class %s" consonant class)
	(message "%s not found as consonant" consonant)))
    class))

(defun pthai-show-consonants-classes()
  "display list of thai consonants with tones in alphabetical order"
  (interactive)
  (let ((classes
	 (seq-group-by (lambda(x) (pthai-lookup-consonant-class x))
		       (car pthai-consonants))))
    (message "mid  %s\nhigh %s\nlow  %s"
	     (cdr (assoc "m" classes))
	     (cdr (assoc "h" classes))
	     (cdr (assoc "l" classes)))))

(defun pthai-show-vowels()
  "display list of thai vowels"
  (interactive)
  (message (string-join pthai-vowels " ")))

(defun pthai-show-numbers()
  "display list of thai numbers"
  (interactive)
  (message (string-join pthai-numbers " ")))

(defun pthai-count-words(&optional start end)
  "count thai words in region or buffer, similar to 'count-words,
but contiguous thai strings will be split up for counting purposes"
  (interactive)
  (let* ((p1 (or start
		 (and (use-region-p) (region-beginning))
		 (point-min)))
	 (p2 (or end
		 (and (use-region-p) (region-end))
		 (point-max)))
	 (lines (count-lines p1 p2))
	 (chars (- p2 p1))
	 (words 0)
	 (p_reporter
	  (make-progress-reporter "Counting..." p1 p2 nil 5 0.5))
	 tmp)
    (save-excursion
      (save-restriction
	(narrow-to-region p1 p2)
	(goto-char (point-min))
	(while (forward-word-strictly 1)
	  (progress-reporter-update p_reporter (point))
	  (setq tmp (pthai-bounds-of-thaiwords-at-point (1- (point)))
		words (+ words (if tmp (length tmp) 1))))
	(progress-reporter-done p_reporter)))
    (when (called-interactively-p 'any)
      (message "%s has %d lines, %d words, and %d characters."
	       (if (use-region-p) "Region" "Buffer")
	       lines words chars))
    words))

(defun pthai-practice-words(wordlist &optional do_class)
  "practice list of thai vocabulary words.  if option do_class,
guess classifier of word.  only thai words with definitions (or
defined classifiers with do_class) should be used, e.g.,
	แมว => cat
or when practicing classifiers
	แมว => ตัว
use custom variable 'pthai-audio-play to disable audio"
  (let* ((prompt
	  "[h/c/l/q/r/s/v/w/x]? ")
	 (help_line
	  "h)elp c)assifier lookup l)ookup q)uit r)epeat audio s)core v)iew words w)rong x)exit")
	 (f_lookup
	  (lambda (x)
	    (if do_class (pthai-classifier-for x) (pthai-lookup x))))
	 right wrong select done word words
	 (p_score
	  (lambda()
	    (let* ((r_num (length right))
		   (w_num (length wrong))
		   (guesses (+ r_num w_num)))
	      (message "%d of %d correct %%%1.0f (total %d)"
		       r_num
		       guesses
		       (* 100.0 (/ (float r_num) (float (max 1 guesses))))
		       (+ guesses (length words)))))))
    (setq wordlist
	  (sort 
	   (seq-filter (lambda(x) (string-match-p "^\\ct+$" x))
		       (delete-dups wordlist))
	   'string-lessp))

    (if (< (length wordlist) 1)
	(message "no defined words found to practice")
      (message "practicing words (%d): %s" (length wordlist) (string-join wordlist " "))
      (sleep-for 1.3)
      (while (not done)
	(setq words (pthai-nrandomize (or words wordlist))
	      right nil
	      wrong nil)
	(while (and words (not done))
	  (setq word (car words))
	  (unless (or (and (not do_class) (pthai-dictionary-def-p word))
		      (and do_class (pthai-classifier-for word)))
	    (error "%s word def/class-info not found in dictionary" word))
	  (when pthai-audio-play
	    (pthai-download-and-play word))
	  (setq select (downcase (read-string (concat word " " prompt))))
	  (cond
	   ;; correct word guess
	   ((seq-contains-p (funcall f_lookup word) select)
	    (setq right (cons word right)
		  words (cdr words))
	    (message "correct")
	    (sit-for 1))
	   ;; help message
	   ((or (string= select "h") (string= select "?"))
	    (message help_line)
	    (sit-for 2))
	   ;; exit/quit
	   ((or (string= select "x") (string= select "q"))
	    (setq done t))
	   ;; lookup
	   ((string= select "l")
	    (pthai-lookup word t)
	    (sit-for 1.5))
	   ;; classifier lookup
	   ((string= select "c")
	    (pthai-classifier-for word t)
	    (sit-for 1.5))
	   ;; repeat
	   ((string= select "r")
	    nil)
	   ;; score
	   ((string= select "s")
	    (funcall p_score)
	    (sit-for 1))
	   ;; view
	   ((string= select "v")
	    (message "words (%d): %s" (length words) (string-join words " "))
	    (sit-for 3))
	   ;; wrong/missed words
	   ((string= select "w")
	    (message "missed words: %s" (string-join wrong " "))
	    (sit-for 1.5))
	   ;; wrong word guess
	   (t
	    (setq wrong (cons word wrong)
		  words (cdr words))
	    (message "wrong %s = %s" word
		     (string-join (funcall f_lookup word) ", "))
	    (sit-for 1))))
	;; display current score
	(when (or right wrong)
	  (funcall p_score)
	  (sit-for 1.5))
	;; keep track of untested/wrong words. unless quitting,
	;; prompt user to continue
	(setq words (append words wrong))
	(unless (string= select "q")
	  (setq select (downcase (read-string "c)ontinue r)eset q)uit? ")))
	  (cond
	   ;; continue
	   ((string= select "c")
	    (setq done nil))
	   ;; reset
	   ((string= select "r")
	    (setq words wordlist
		  done nil))
	   ;; default/quit
	   (t
	    (setq done t))))))))

(defun pthai-practice-region(p1 p2)
  "practice thai words extracted from a region in the current
buffer. only words that have definitions will be used"
  (interactive "r")
  (let ((buf (current-buffer)))
    (with-temp-buffer
      (insert-buffer-substring buf p1 p2)
      (pthai-split-buffer)
      (pthai-practice-words
       (seq-filter
	'pthai-dictionary-def-p
	(split-string
	 (buffer-substring-no-properties (point-min) (point-max))))))))

(defun pthai-practice-line()
  "practice thai words on current line, only words that have
definitions will be used"
  (interactive)
  (pthai-practice-region (line-end-position) (line-beginning-position)))

(defun pthai-practice-buffer()
  "practice thai words in current buffer, only words that have
definitions will be used"
  (interactive)
  (pthai-practice-region (point-min) (point-max)))

(defun pthai-practice-file(file)
  "practice thai words from file, only words that have definitions
will be used"
  (interactive "ffile: ")
  (let ((coding-system-for-read 'utf-8)
	(buffer-file-coding-system 'utf-8))
    (with-temp-buffer
      (insert-file-contents file)
      (pthai-practice-buffer))))

(defun pthai-classifier-practice-region(p1 p2)
  "practice thaiwords extracted from a region in the current
buffer. only words with known classifiers will be used"
  (interactive "r")
  (let ((buf (current-buffer))
	(cls (pthai-classifier-list))
	(ht (make-hash-table :test 'equal))
	bwords rlist)
    (dolist (c cls)
      (dolist (w (pthai-classifier-by c))
	(puthash w 't ht)))
    (message "%d words classified" (hash-table-count ht))
    (with-temp-buffer
       (insert-buffer-substring buf p1 p2)
       (pthai-split-buffer)
       (setq bwords
	     (split-string
	      (buffer-substring-no-properties (point-min) (point-max)))))
    (dolist (w bwords)
      (if (gethash w ht)
	  (setq rlist (cons w rlist))))
    (pthai-practice-words rlist t)))

(defun pthai-classifier-practice-line()
  "practice thai words with defined classifiers on current line"
  (interactive)
  (pthai-classifier-practice-region
   (line-end-position) (line-beginning-position)))

(defun pthai-classifier-practice-buffer()
  "practice thai words with defined classifiers in current buffer"
  (interactive)
  (pthai-classifier-practice-region (point-min) (point-max)))

(defun pthai-classifier-practice-file(file)
  "practice thai words from file with defined classifiers"
  (interactive "ffile: ")
  (let ((coding-system-for-read 'utf-8)
	(buffer-file-coding-system 'utf-8))
    (with-temp-buffer
      (insert-file-contents file)
      (pthai-classifier-practice-buffer))))

(defun pthai-unknown-bounds(p1 p2)
  "return list of bounds of unknown thai words in region between p1 and p2"
  (let ((p_reporter
	 (make-progress-reporter "Finding unknowns..." p1 p2 nil 5 0.5))
	word bounds)
    (save-excursion
      (save-restriction
	(narrow-to-region p1 p2)
	(goto-char (point-min))
	(while (re-search-forward pthai-regexp-spelling-chars nil t)
	  (progress-reporter-update p_reporter (point))
	  (dolist (bound (pthai-bounds-of-thaiwords-at-point (match-beginning 1)))
	    (setq word (buffer-substring-no-properties (car bound) (cdr bound)))
	    (unless (or (pthai-dictionary-any-p word)
			(string-match-p pthai-regexp-single-consonant word))
	      (setq bounds (cons bound bounds)))))
	(progress-reporter-done p_reporter)
	(nreverse bounds)))))

(defun pthai-unknown-words(&optional start end)
  "return list of unknown thai words from buffer or region"
  (interactive)
  (let* ((p1 (or start
		 (and (use-region-p) (region-beginning))
		 (point-min)))
	 (p2 (or end
		(and (use-region-p) (region-end))
		(point-max)))
	 (words
	  (delete-dups
	   (mapcar (lambda(x) (buffer-substring-no-properties (car x) (cdr x)))
		   (pthai-unknown-bounds p1 p2)))))
    (when (called-interactively-p 'any)
      (if (not words)
	  (message "no unknown words found")
	(message "%d words found: %s" (length words) (string-join words " "))))
    words))

(defun pthai-unknown-words-count()
  "return number of unknown thai words in buffer or region"
  (interactive)
  (let ((len (length (pthai-unknown-words))))
    (when (called-interactively-p 'any)
      (message "%d unknown words" len))
    len))

(defun pthai-unknown-words-extract()
  "insert unknown thai words from a region or buffer into a new temp
buffer, in dictionary format.  unknown words that aren't in
pthai-dictionary are prefixed with '#'"
  (interactive)
  (let ((words (pthai-unknown-words))
	(bname "*pthai-unknown-words-extract*"))
    (if (not words)
	(message "no unknown words found")
      (with-temp-buffer
	 (insert
	  (mapconcat
	   (lambda(x) (if (pthai-dictionary-p x) x (concat "#" x ))) words "\n"))
	 (pthai-message bname (buffer-string) t))
      (message "found %d unknown words" (length words)))))

(defun pthai-unknown-words-toggle(&optional start end wtype)
  "toggle highlighting (using overlays) of words not in
 dictionary and words in dictionary but undefined, in a buffer or
 region of thai text.
  if option wtype is 'dictionary, only toggle words in
dictionary without definitions
  if options wtype is 'unknown, only toggle words not in
dictionary. return number of words (overlays) toggled
  if option wtype is nil, toggle both undefined and unknown
words (default)
  when unhighlighting, all overlays are toggled-off, regardless
of wtype"
  (interactive)
  (let* ((p1 (or start
		 (and (use-region-p) (region-beginning))
		 (point-min)))
	 (p2 (or end
		 (and (use-region-p) (region-end))
		 (point-max)))
	 ;; bounds of any existing overlays
	 (o_bounds
	  (mapcar
	   (lambda(x) (cons (overlay-start x) (overlay-end x)))
	   (seq-filter (lambda(x) (overlay-get x 'pthai)) (overlays-in p1 p2))))
	 ;; bounds of new overlays to create
	 (n_bounds
	  (unless o_bounds
	    (let ((a_bounds (pthai-unknown-bounds p1 p2)))
	      (cond
	       ((eq wtype 'dictionary)
		(seq-filter
		 (lambda(x)
		   (pthai-dictionary-p (buffer-substring-no-properties (car x) (cdr x))))
		 a_bounds))
	       ((eq wtype 'unknown)
		(seq-remove
		 (lambda(x)
		   (pthai-dictionary-p (buffer-substring-no-properties (car x) (cdr x))))
		 a_bounds))
	       (t
		a_bounds)))))
	 (wlist
	  (mapcar (lambda(x) (buffer-substring-no-properties (car x) (cdr x)))
		  (or o_bounds n_bounds)))
	 (wlen
	  (length wlist)))
    (if (= wlen 0)
	(message "no words found")
      (if o_bounds
	  (mapc (lambda(x) (pthai-highlight-off (car x) (cdr x))) o_bounds)
	(mapc (lambda(x) (pthai-highlight-on (car x) (cdr x))) n_bounds))
      (message "%d unknowns (%d unique) toggled %s"
	       wlen (length (seq-uniq wlist)) (if o_bounds "off" "on")))
    wlen))

(defun pthai-unknown-words-toggle-dictionary(&optional start end)
  "same as pthai-unknown-words-toggle but only toggle words in dictionary"
  (interactive)
  (pthai-unknown-words-toggle start end 'dictionary))

(defun pthai-unknown-words-toggle-unknown(&optional start end)
  "same as pthai-unknown-words-toggle but only toggle undefined
words, i.e., words not in dictionary"
  (interactive)
  (pthai-unknown-words-toggle start end 'unknown))

(defun pthai-unknown-words-toggle-line()
  "same as pthai-unknown-words-toggle but for current line"
  (interactive)
  (pthai-unknown-words-toggle (line-beginning-position)
			      (line-end-position)))

(defun pthai-currency-x-rates()
 "from www.x-rates.com, return an alist of supported currencies and their names
'((\"USD\" . \"US Dollar\") (\"THB\" . \"Thai Baht\") ... )"
  (let* ((x_url "https://www.x-rates.com")
	 (currency_rexp
	  (concat "<li><a href='" x_url "/table/\\?from=\\([A-Z]\\{3\\}\\)' "
		  "onclick=.* rel='ratestable'>\\(.*\\)</a></li>"))
	 clist)
    (with-current-buffer (url-retrieve-synchronously x_url)
      (goto-char (point-min))
      ;; match a string like
      ;;<li><a href='https://www.x-rates.com/table/?from=ARS' onclick="submitConverterArgs(this)"
      ;; rel='ratestable'>Argentine Peso</a></li>
      (while (re-search-forward currency_rexp nil t)
	(setq clist (cons (cons (match-string 1) (match-string 2)) clist))))
    (unless (and (assoc "THB" clist) (assoc "USD" clist))
      (error "error fetching currency types"))
    (reverse clist)))

(defun pthai-currency-convert(&optional from_curr to_curr amount)
  "convert between arbitrary currencies, prompt user for optional
values or if input values incorrect. message user if called
interactively.  returns converted currency as a floating point number.

args from_curr and to_curr args are 3 letter currency
abbreviations, amount is the currency value to be converted"
  (interactive)
  (let* ((x_url "https://www.x-rates.com")
	 (c_list (pthai-currency-x-rates))
	 (c_curr (mapcar 'car c_list))
	 (c_choices (mapcar (lambda (x)
			       (cons (format "%s (%s)" (cdr x) (car x)) (car x)))
			     c_list))
	 from_desc to_desc converted)
    ;; determine orignal and converted currency types
    (unless (seq-contains-p c_curr from_curr)
      (setq from_curr
	    (cdr (assoc (completing-read "from currency: " c_choices nil t) c_choices))))
    (setq from_desc (cdr (assoc from_curr c_list)))
    (unless (seq-contains-p c_curr to_curr)
      (setq to_curr
	    (cdr (assoc (completing-read "to currency: " c_choices nil t) c_choices))))
    (setq to_desc (cdr (assoc to_curr c_list)))
    (unless (and from_desc to_desc)
      (error "unknown currencies (%s) (%s)" from_curr to_curr))
    ;; amount of currency to convert
    (while (or (not amount) (not (numberp amount)) (<= amount 0.0))
      (setq amount
	    (read-number
	     (concat "amount of " from_desc " (" from_curr ") to convert: ") 1.0)))
    (setq amount (number-to-string amount))
    ;; submit conversion url searching for the converted value, like
    ;; <a href='https://www.x-rates.com/graph/?from=USD&amp;to=THB'>30.171404</a>
    ;; error if no match found
    (with-current-buffer (url-retrieve-synchronously
			  (concat x_url "/table/?from=" from_curr "&amount=" amount))
      (goto-char (point-min))
      (re-search-forward (concat x_url "/graph/\\?from="
				 from_curr "&amp;to=" to_curr "'>\\([0-9.]+\\)</a>"))
      (setq converted (format "%3.2f" (string-to-number (match-string 1)))))
    
    (if (not (or executing-kbd-macro noninteractive))
	(message "%s %s (%s) = %s %s (%s)"
		 amount from_desc from_curr converted to_desc to_curr))
    (string-to-number converted)))

(defun pthai-currency-dollar-to-baht(&optional amount)
  "convert us dollars to baht"
  (interactive)
  (pthai-currency-convert "USD" "THB" amount))

(defun pthai-currency-baht-to-dollar(&optional amount)
  "convert baht to us dollars"
  (interactive)
  (pthai-currency-convert "THB" "USD" amount))

(define-minor-mode pthai-mode
  "Toggle pthai-mode.  Minor mode to add functionality for
working with Thai text.  Adds menu of main supported elements of
'pthai module, and key re-mappings affecting word movement and
editing of Thai text.

The key commands affected are \\[right-word], \\[left-word],
\\[kill-word], \\[backward-kill-word], and \\[transpose-words]"
  ;; initial value
  :init-value nil
  :lighter "pthai"
  :keymap
  '(([remap right-word] . forward-thaiword)
    ([remap left-word] . backward-thaiword)
    ([remap kill-word] . pthai-kill-word)
    ([remap backward-kill-word] . pthai-backward-kill-word)
    ([remap transpose-words] . pthai-transpose-words)
    ([remap ispell-complete-word] . pthai-complete-word)
    ;;xxx ([M-tab] . pthai-complete-word)
    ("\C-xpai"  . pthai-say)
    ("\C-xpaw"  . pthai-say-word-at-point)
    ("\C-xpas"  . pthai-say-string-at-point)
    ("\C-xpal"  . pthai-say-line)
    ("\C-xpaol" . pthai-say-line-thai-only)
    ("\C-xpab"  . pthai-say-buffer)
    ("\C-xpaob" . pthai-say-buffer-thai-only)
    ("\C-xpar"  . pthai-say-region)
    ("\C-xpaot" . pthai-say-region-thai-only)
    ("\C-xpss"  . pthai-split-string-at-point)
    ("\C-xpsl"  . pthai-split-line)
    ("\C-xpsf"  . pthai-split-line-from-point)
    ("\C-xpsr"  . pthai-split-region)
    ("\C-xpsb"  . pthai-split-buffer)
    ("\C-xpul"  . pthai-unsplit-line)
    ("\C-xpur"  . pthai-unsplit-region)
    ("\C-xpub"  . pthai-unsplit-buffer)
    ("\C-xpcw"  . pthai-spell-word-at-point)
    ("\C-xpcs"  . pthai-spell-string-at-point)
    ("\C-xpcl"  . pthai-spell-line)
    ("\C-xpcb"  . pthai-spell-buffer)
    ("\C-xpcf"  . pthai-spell-buffer-from-point)
    ("\C-xpcr"  . pthai-spell-region)
    ("\C-xplu"  . pthai-lookup)
    ("\C-xplp"  . pthai-lookup-at-point)
    ("\C-xplc"  . pthai-classifier-lookup-at-point)
    ("\C-xpli"  . pthai-lookup-and-insert)
    ("\C-xpcm"  . pthai-complete-word)
    ("\C-xpdg"  . pthai-dictionary-grep)
    ("\C-xpdr"  . pthai-dictionary-read-files)
    ("\C-xpxb"  . pthai-currency-baht-to-dollar)
    ("\C-xpxd"  . pthai-currency-dollar-to-baht)))

(provide 'pthai)
