;;; -*- Mode:LISP; Package:FTP; Readtable:CL; Base:10 -*-

#|

  Copyright LISP Machine, Inc. 1987
   See filename "Copyright.Text" for
  licensing and release information.

|#

(defmacro defopt (symbol command-fmt)
  `(setf (get ',symbol :defopt-fmt) ,command-fmt))

;;; modes
(defopt stream "MODE S")
(defopt block "MODE B")
(defopt compressed "MODE C")

;;; types
(defopt ascii "TYPE A")
(defopt ebcdic "TYPE E")
(defopt image "TYPE I")
(defopt binary "TYPE I")
(defopt tenex "TYPE L ~D")
(defopt 16bit "TYPE L 16")

;;; forms
(defopt non-print "FORM N")
(defopt telnet "FORM T")
(defopt carriage-control "FORM C")

;;; structs
(defopt file "STRU F")
(defopt record "STRU R")
(defopt page "STRU P")


(defvar *auto-login*)
(defvar *trace*)
(defvar *hash* nil)
(defvar *sendport*)
(defvar *verbose*)
(defvar *debug*)
(defvar *bell*)
(defvar *glob*)
(defvar *prompt*)
(defvar *type*)
(defvar *struct*)
(defvar *form*)
(defvar *mode*)
(defvar *bytesize*)
(defvar *remote-hostname*)
(defvar *connected*)
(defvar *user*)
(defvar *pass*)
(defvar *acct*)
(defvar *control*)
(defvar *data*)
(defvar *history*)
(defvar *last-reply*)

(eval-when (eval compile load)
;;; this is needed at compile time by the macro defcmd.

(defvar *ftp-command-alist* '(nil))
(defvar *ftp-connected-command-list* nil)
)

(defun find-command-entry (name)
  (let* (match-entry
	 (match-count 0)
	 (length (length name)))
    (dolist (entry *ftp-command-alist*)
      (when (null entry)
	(cond ((= match-count 0)
	       (format t "~&?Invalid command~%")
	       (return-from find-command-entry nil))
	      ((= match-count 1)
	       (return-from find-command-entry match-entry))
	      (t
	       (format t "~&?Ambiguous command~%")
	       (return-from find-command-entry nil))))
      (when (string-equal name (car entry))
	(return-from find-command-entry entry))
      (when (string-equal name (car entry) :end1 length :end2 length)
	(incf match-count)
	(setq match-entry entry)))))

(defun is-required-arg (prompt)
  (/= #\[ (aref prompt 0)))

(defun execute-ftp-command-list (command-list &aux entry)
  (when command-list
    (setq entry (find-command-entry (car (last command-list)))))
  (when entry
    (let* ((func-name (car entry))
	   (func-sym (cdr entry))
	   (prompt-list (nthcdr (1- (length command-list))
				(get func-sym :prompt-list)))
	   input)
      (when (and (member func-sym *ftp-connected-command-list*)
		 (not *connected*))
	(format t "~&Connect to a remote host before doing ~S" func-name)
	(return-from execute-ftp-command-list nil))
      (when (and prompt-list (is-required-arg (car prompt-list)))
	(dolist (prompt prompt-list)
	  (fresh-line)
	  (setq input (global:prompt-and-read :string-or-nil prompt))
	  (when (and (null input) (is-required-arg prompt))
	    (when *bell*
	      (global:beep))
	    (format t "~&~A ~A~%" func-name (get func-sym :usage-string))
	    (return-from execute-ftp-command-list nil))
	  (push input command-list)))
      (setq command-list
	    (do () ((car command-list) (reverse command-list))
	      (pop command-list)))
      
      ;; typing  blows away our connection to the remote-host; this should be fixed.
      ;; this condition-case is here to prevent  from bombing us out of ftp.
      (global:condition-case ()
	  (apply func-sym (cdr command-list))
	(sys:abort)))))

(defun parse-line-into-list (line)
  (when line
    (let ((end 0)
	  command-list)
      (do (start)
	  ((null (multiple-value-setq (start end) (fs:string-find-token line end)))
	   command-list)
	(push (substring line start end) command-list)))))


(defun hookup (host port &optional (keyword "FTP Control Connection"))
  (setq *remote-hostname* (if (numberp host)
			      (format nil "~X" host)
			    (string host)))
  (let ((tcp:*tcp-stream-whostate* "Open Control Connection"))
    (setq *control* (open (string-append "TCP-HOST:" host "." port)
			  :keyword keyword))
    (setq *connected* t)
    (if *verbose* (format t "~&Connected to ~S~%" host))
    (getreply nil)))

(defun ftp-getreply (expecteof)
  (let ((tcp:*tcp-stream-whostate* "Reply"))
    (getreply expecteof)))

(defun getreply (expecteof)
  (push-history)
  (catch 'lostpeer
    (prog (code continuationp)
       error-loop
	  (multiple-value-setq (code continuationp) (getreply-line expecteof))
	  (cond ((null code)
		 ;; an error condition, first line must contain a code
		 ;; Sometimes ftp servers have bugs the cause system error
		 ;; messages and other garbage down the line.
		 ;; This code skips over such lines:
		 (go error-loop))
		(continuationp
		 (getreply-recursive code expecteof)))
	  (return (values (floor code 100) code)))))

(defun getreply-recursive (code expecteof)
  (prog ()
     tail-recursive
	(multiple-value-bind (new-code continuationp)
	    (getreply-line expecteof)
	  (cond ((null new-code)
		 (go tail-recursive))
		((eq new-code code)
		 (cond (continuationp
			(go tail-recursive))
		       ('else
			(return nil))))
		(continuationp
		 ;; this is a nested message and how i think it
		 ;; is proper to handle. never have seen one though.
		 (return (getreply-recursive new-code expecteof)))
		('else
		 (go tail-recursive))))))


(defun getreply-line (expecteof)
  (labels ((peekc ()
		  (or (and *control* (send *control* :tyipeek))
		      (if expecteof nil (lostpeer))))
	   (getc ()
		 (let ((c (and *control* (send *control* :tyi))))
		   (or expecteof c (lostpeer))
		   (or (not c) (= c (sym lf))
		       (history-record-char (if (= c (sym cr)) #\return c)))
		   (cond ((or (not c) (not *verbose*)))
			 ((= c (sym lf)))
			 ((= c (sym cr))
			  (terpri))
			 ('else
			  (write-char c)))
		   c)))
    (prog (code j c weight continuationp)
	  (when *verbose* (format t "~&"))
	  (setq code 0 j 0)
       get-code
	  (cond ((not (= j 3)))
		((eq #\- (int-char (peekc)))
		 (setq continuationp t)
		 (getc)
		 (go get-crlf))
		('else
		 (go get-crlf)))
	  (setq c (getc))
	  (cond ((null c)
		 (return (values -1 nil)))
		((null (setq weight (digit-char-p c)))
		 (setq code nil)
		 (go get-crlf))
		('else
		 (setq code (+ (* code 10) weight))
		 (incf j)
		 (go get-code)))
       get-crlf
	  (setq c (getc))
	  (cond ((null c)
		 (return (values code continuationp)))
		((= c (sym cr))
		 (setq c (getc))
		 (or (eq c (sym lf))
		     (not *debug*)
		     (error "expecting LF after CR but got: ~@C" c))
		 (return (values code continuationp)))
		('else
		 (go get-crlf))))))

(defun close-control-connection ()
  (when *control*
    (let ((tcp:*tcp-stream-whostate* "Close Control Connection"))
      (unwind-protect
	  (close *control*)
	(setq *control* nil)))))

(defun close-data-connection ()
  (when *data*
    (let ((tcp:*tcp-stream-whostate* "Close Data Connection"))
      (unwind-protect
	  (close *data*)
	(setq *data* nil)))))

(defun lostpeer ()
  (when *connected*
    (setq *type* 'ascii)
    (setq *struct* 'file)
    (setq *form* 'non-print)
    (setq *mode* 'stream)
    (setq *bytesize* 8.)
    (close-control-connection)
    (close-data-connection)
    (setq *connected* nil)
    (let ((s (last-reply))
	  (bogo "599 server randomly died, lost connection, did not print this"))
      (when s
	(setf (fill-pointer s) (length bogo))
	(copy-array-contents bogo s)))
    (throw 'lostpeer (values (sym error) 599))))


(defvar *ignore-reply-from-quit* nil)

(defun ftp-command (fmt &rest args)
  "Send an FTP command to the control connection.  For use within (ftp:ftp)"
  (let ((tcp:*tcp-stream-whostate* "Command"))
    (command-1 (apply #'format nil fmt args))))

(defun command (fmt &rest args)
  "Send an FTP command to the control connection."
  (command-1 (apply #'format nil fmt args)))

(defun command-1 (command-string)
  (catch 'lostpeer
    (unless *control*
      (and *debug* (format *error-output* "~&No control connection for command~%"))
      (return-from command-1 0))

    (do ()
	((not (send *control* :listen)))
      ;;Server has a bug -- shouldn't be extra replies here!
      (push-history)
      (let ((string *last-reply*))
	(copy-array-contents "BUG: " string)
	(setf (fill-pointer string) 5)
	(do ((c nil))
	    ((eq c #\return))
	  (setq c (send *control* :tyi))
	  (when (null c) (lostpeer))
	  (unless (eq c (sym cr))
	    (when (eq c (sym lf))
	      (setq c #\return))
	    (history-record-char c)))
	(when *verbose*
	  (format t "~&~A" string))))
    
    (if *debug* (format t "~&---> ~A~%" command-string))
    (unless (eq *history* :dont-record)
      (push (string-append "" command-string) *history*))
    (send *control* :string-out command-string)
    (send *control* :tyo (sym cr))
    (send *control* :tyo (sym lf))
    (send *control* :force-output)
    (getreply (if (string-equal command-string "QUIT")
		  (if *ignore-reply-from-quit*
		      (return-from command-1 nil)
		    t)))
    ))

(defun commandp (value fmt &rest args)
  (= value (apply #'command fmt args)))

;; data connection handling: RECVREQUEST and SENDREQUEST

(defun recvrequest (cmd local remote)
  "local is a string describing the local place to put the
requested data. remote is a remote description."
  (prog (start stop bytes din fout)
	(setq bytes 0)
	(unwind-protect
	    (progn (when (initconn :input)
		     (go done))
		   (let ((x (if remote
				(ftp-command "~A ~A" cmd remote)
			      (ftp-command cmd))))
		     (unless (= x (sym prelim))
		       (go done)))
		   (setq din (dataconn :input))
		   (if (not din) (go bad))
		   (setq fout (cond ((null local) *standard-output*)
				    ((streamp local) local)
				    ((stringp local)
				     (if (eq *type* '16bit)
					 (make-8b-to-16b-translating-output-stream
					   (open local :direction :output :byte-size 16 :characters nil))
				       (open local :direction :output)))))
		   (setq start (time:time))
		   (let ((tcp:*tcp-stream-whostate* "Net File Input"))
		     (global:stream-copy-until-eof din fout))
		   (setq stop (time:time))
		   (setq bytes (send din :bytes)))
	  (unless stop
	    (setq stop (time:time))
	    (if din (setq bytes (send din :bytes))))
	  (close-data-connection)
	  (when fout
	    (unless (eq fout *standard-output*)
	      (close fout))))
     bad
	(ftp-getreply nil)
     done
	(if (and (> bytes 0) *verbose*)
	    (ptransfer "received" bytes start stop))
	(return nil)))

(defun sendrequest (cmd local remote)
  (prog (start stop bytes dout)
	(setq bytes 0)
	(unwind-protect
	    (progn (if (initconn :output) (go bad))
		   (unless (= (ftp-command "~A ~A" cmd remote) (sym prelim))
		     (go done))
		   (setq dout (dataconn :output))
		   (if (null dout) (go bad))
		   (when (eq *type* '16bit)
		     (setq dout (make-16b-to-8b-translating-output-stream dout)))
		   (with-open-stream (fin (if (eq *type* '16bit)
					      (open local :byte-size 16 :characters nil)
					    (open local)))
		     (setq start (time:time))
		     (let ((tcp:*tcp-stream-whostate* "Net File Output"))
		       (global:stream-copy-until-eof fin dout))
		     (send dout :force-output)
		     (setq stop (time:time))
		     (setq bytes (send dout :bytes))))
	  (unless stop
	    (setq stop (time:time))
	    (if dout (setq bytes (send dout :bytes))))
	  (close-data-connection))
	(close dout)
     bad
	(ftp-getreply nil)
     done
	(if (and (> bytes 0) *verbose*)
	    (ptransfer "sent" bytes start stop))
	(return nil)))

(defun ptransfer (direction bytes t0 t1)
  (if *verbose*
      (let ((sec (/ (- t1 t0) 60.0)))
	(if (zerop sec) (setq sec 1))
	(format t "~D bytes ~A in ~$ seconds (~$ Kbytes per second)~%"
		bytes direction
		sec
		(/ bytes (* sec 1000))))))

#|
Parts of the initconn and dataconn combination should be
abstracted into the :OPEN method for TCP-HOST. Use of WITH-OPEN-FILE
in RECVREQUEST and SENDREQUEST could be prefered.
|#

(defparameter *ftp-buffers* 16. "Number of buffers for a data connection")

(defun initconn (direction &optional buffers optimistic)
  (unless (numberp buffers)
    (setq buffers *ftp-buffers*))
  (close-data-connection)
  (let ((addr nil)
	(port nil)
	(result nil)
	(tcp:*tcp-stream-whostate* "Open Data Connection"))
    (setq *data* (tcpa:open-easy-tcp-stream (send *control* :remote-address)
					    (sym-value 'tcpa:ipport-ftp-data)
					    (unless *sendport*
					      (send *control* :local-port))
					    :direction direction
					    :input-buffers (ecase direction
							     (:input buffers)
							     (:output 0))
					    :output-buffers (ecase direction
							      (:output buffers)
							      (:input 0))
					    :optimistic optimistic
					    :keyword "FTP Data Connection"
					    :connect nil))
    (when *sendport*
      (setq addr (send *data* :local-address))
      (setq port (send *data* :local-port))
      (setq result (command "PORT ~D,~D,~D,~D,~D,~D"
			    (ldb (byte 8 24) addr)
			    (ldb (byte 8 16) addr)
			    (ldb (byte 8 8) addr)
			    (ldb (byte 8 0) addr)
			    (ldb (byte 8 8) port)
			    (ldb (byte 8 0) port)))
	(if (= result (sym error))
	    (let ((*sendport* nil))
	      (initconn direction))
	  (not (= result (sym complete)))))))

(defun dataconn (direction &optional (translator-maker 'make-translating-stream))
  ;; arrange to return NIL here if the accept times out or other network error.
  (send *data* :accept)
  (funcall translator-maker *data* direction (cond ((eq *type* 'ascii) :ascii)
						 ((eq *type* 'ebcdic) :ebcdic))))

(defun close-dataconn (&optional (getreply t))
  (close-data-connection)
  (and getreply *connected* (ftp-getreply nil)))

#|
Character translation hair. The operations provided by the
stream-default-handler are:
 :tyipeek :listen
 :any-tyi :tyi-no-hang
 :any-tyi-no-hang
 :read-char
 :any-read-char :read-char-no-hang
 :any-read-char-no-hang
 :read-byte
 :unread-char
 :write-char
 :write-byte
 :clear-output :clear-input :force-output :finish :close :eof
 :fresh-line
 :string-out :line-out
 :line-in
 :string-in
 :string-line-in
 :operation-handled-p
 :characters
 :element-type
 :direction
 :send-if-handles

Although we are only concerned with what stream-copy-until-eof will
send, which is :read-input-buffer on :input, and :string-out on :output.

|#

(defun make-translating-stream (raw-stream direction typearg)
  (ecase direction
    (:input
     (ecase typearg
       (:ascii
	(make-ascii-translating-input-stream raw-stream))
       (nil
	(make-non-translating-input-stream raw-stream))))
    (:output
     (ecase typearg
       (:ascii
	(make-ascii-translating-output-stream raw-stream))
       (nil
	(make-non-translating-output-stream raw-stream))))))

(defun make-non-translating-input-stream (raw-stream &optional (hash-marks t) &aux (bytes 0) stream)
  (setq stream
	#'(lambda (op &optional arg1 &rest args)
	    (si:selectq-with-which-operations op
	      (:read-input-buffer
		(multiple-value-bind (buf offset limit)
		    (send raw-stream :read-input-buffer)
		  (cond ((null buf)
			 ())
			('else
			 (if (and hash-marks *hash*) (princ "#"))
			 (incf bytes (- limit offset))
			 (values buf offset limit)))))
	      (:advance-input-buffer
		(send raw-stream :advance-input-buffer))
	      (:bytes
		(if (and hash-marks *hash*) (terpri))
		bytes)
	      (:close
		(close raw-stream))
	      (t
		(global:stream-default-handler stream op arg1 args))))))

(defun make-ascii-translating-input-stream (raw-stream &optional (hash-marks t)  &aux (bytes 0) stream)
  (setq stream
	#'(lambda (op &optional arg1 &rest args)
	    (si:selectq-with-which-operations op
	      (:read-input-buffer
		(multiple-value-bind (buf offset limit)
		    (send raw-stream :read-input-buffer)
		  (cond ((null buf)
			 nil)
			('else
			 (do ((j offset (1+ j))
			      (i offset)
			      (c))
			     ((= j limit)
			      (if (and hash-marks *hash*) (princ "#"))
			      (incf bytes (- i offset))
			      (values buf offset i))
			   (cond ((= 13 (setq c (aref buf j)))
				  ;; theory is to ignore CR in the CRLF sequence
				  ;; because somebody might send CR LF LF to mean
				  ;; two lines, and it is the LF that carry the meaning.
				  ;; An actual reading of the protocal handbook
				  ;; might not be a bad idea.
				  )
				 ((= c 10)
				  (setf (aref buf i) #\return)
				  (incf i))
				 ((or (= c 8) (= c 9) (= c 12) (= c 13))
				  (setf (aref buf i) (+ c #o200))
				  (incf i))
				 ('else
				  (setf (aref buf i) c)
				  (incf i))))))))
	      (:advance-input-buffer
		(send raw-stream :advance-input-buffer))
	      (:bytes
		(if (and hash-marks *hash*) (terpri))
		bytes)
	      (:close
		(close raw-stream))
	      (t
		(global:stream-default-handler stream op arg1 args))))))

(defun make-non-translating-output-stream (raw-stream &optional (hash-marks t) &aux (bytes 0) stream)
  (setq stream
	#'(lambda (op &optional arg1 &rest args)
	    (si:selectq-with-which-operations op
	      (:string-out
		(let ((buff arg1)
		      (offset (car args))
		      (limit (cadr args)))
		  (when (and hash-marks *hash*)
		    (dotimes (i (truncate (- limit offset) 1024))
		      (princ "#")))
		  (incf bytes (- limit offset))
		  (send raw-stream :string-out buff offset limit)))
	      (:close
		(close raw-stream))
	      (:bytes
		(if (and hash-marks *hash*) (terpri))
		bytes)
	      (t
		(global:stream-default-handler stream op arg1 args))))))

(defun make-ascii-translating-output-stream (raw-stream &optional (hash-marks t) &aux (bytes 0) stream)
  (setq stream
	#'(lambda (op &optional arg1 &rest args)
	    (si:selectq-with-which-operations op
	      (:tyo
		(incf bytes)
		(let ((c arg1))
		  (cond ((= c #\return)
			 (send raw-stream :tyo (sym cr))
			 (send raw-stream :tyo (sym lf)))
			('else
			 (if (> c #o200)
			     (setq c (- c #o200)))
			 (send raw-stream :tyo c)))))
	      (:string-out
		(let ((buff arg1)
		      (offset (car args))
		      (limit (cadr args)))
		  (or offset (setq offset 0))
		  (or limit (setq limit (length buff)))
		  (if (and hash-marks *hash*) (princ "#"))
		  (incf bytes (- limit offset))
		  (labels ((outbuff (end)
				    (do ((j offset (1+ j))
					 (s buff)
					 (c))
					((= j end)
					 (send raw-stream :string-out s offset end))
				      (if (> (setq c (aref s j)) #o200)
					  (setf (aref s j) (- c #o200)))))
			   (outc (c) (send raw-stream :tyo c)))
		    (do ((n))
			((null (setq n (global:string-search-char #\return buff offset limit)))
			 (outbuff limit))
		      (outbuff n)
		      (outc (sym cr))
		      (outc (sym lf))
		      (setq offset (1+ n))))))
	      (:close
		(close raw-stream))
	      (:bytes
		(if (and hash-marks *hash*) (terpri))
		bytes)
	      (t
		(global:stream-default-handler stream op arg1 args))))))

;;; FTP command table and commands

(eval-when (eval compile load)
;;; the following two functions are needed at compile time by the macro defcmd.
  
(defun usage-string (arglist)
  (let (optional (usage ""))
    (dolist (arg arglist usage)
      (cond ((eq arg '&optional)
	     (setq optional t))
	    ((eq arg '&rest))
	    (t
	     (when (listp arg)
	       (setq arg (car arg)))
	     (setq usage (string-append usage
					(format nil " ~:[~;[ ~]~(~A~)~:[~; ]~]" optional arg optional))))))))

(defun prompt-list (arglist)
  (let (optional prompt)
    (cond ((null arglist)
	   nil)
	  ((eq (car arglist) '&optional)
	   nil)
	  ((eq (car arglist) '&rest)
	   `(,(format nil "(~(~A~)) " (cadr arglist))))
	  (t
	   (dolist (arg arglist (reverse prompt))
	     (cond ((eq arg '&optional)
		    (setq optional t))
		   (t
		    (when (listp arg)
		      (setq arg (car arg)))
		    (push (format nil "~:[(~;[~]~(~A~)~:[)~;]~] " optional arg optional) prompt))))))))
)

(defmacro defcmd (cmd-names arglist connection-needed-p &body body)
  (declare (zwei:indentation 2 1))
  (when (atom cmd-names)
    (setq cmd-names (list cmd-names)))
  (let ((cmd-sym (car cmd-names))
	(usage (usage-string arglist))
	(prompt (prompt-list arglist))
	(forms nil))
    
    ;; hack arglist so the cmd-func will tolerate
    ;; any number of arguments passed to it.
    (unless (member '&rest arglist :test #'eq)
      (setq arglist `(,@arglist &rest ignore)))
    
    (dolist (cmd-name cmd-names)
      (let ((name (substring (format nil "~(~A~)" cmd-name) 4)))
	(push `(progn
		 (global:record-source-file-name ',cmd-name 'defcmd)
		 (unless (assoc ,name *ftp-command-alist* :test #'string-equal)
		   (push `(,',name . ,',cmd-sym) *ftp-command-alist*))
		 (when ,connection-needed-p
		   (unless (member ',cmd-sym *ftp-connected-command-list* :test #'eq)
		     (push ',cmd-sym *ftp-connected-command-list*)))
		 )
	      forms)))
    
    `(progn
       ;; add all aliases into the alist of command names.
       ;; notice that names in the table have the "cmd-" prefix stripped.
       (eval-when (eval compile load)
	 ,@forms)
       (defun ,cmd-sym ,arglist
	 ,@body)
       (setf (get ',cmd-sym :usage-string) ',usage)
       (setf (get ',cmd-sym :prompt-list) ',prompt))))

#|
 (defcmd (testit tstit) (&rest fwazz1)
  "document string"
  (format t "~&fwazz1=~A; fwazz2=~A; fwazz3=~A" fwazz1 fwazz2 fwazz3))
|#


(defun multiple-command (prompt func-spec arg-list)
  (when (= 1 (length arg-list))
    (setq arg-list (parse-line-into-list (car arg-list))))
  (dolist (arg arg-list)
    (when (or (not prompt) (y-or-n-p (format nil "~A ~A? " prompt arg)))
      (funcall func-spec arg))))

(defun wildcard-multiple-command (prompt func-spec arg-list)
  (when (= 1 (length arg-list))
    (setq arg-list (parse-line-into-list (car arg-list))))
  (dolist (arg arg-list)
    (dolist (file (expand-remote-wildcard arg))
      (when (or (not prompt) (y-or-n-p (format nil "~A ~A? " prompt file)))
	(funcall func-spec file)))))

(defun expand-remote-wildcard (name)
  (with-input-from-string (string (with-output-to-string (s)
				    (recvrequest "NLST" s name)))
    (do* ((result nil)
	  (line (read-line string) (read-line string)))
	 ((null line)
	  (nreverse result))
      (push line result))))
    
;;; file transfer commands

(defcmd cmd-append (local-file &optional (remote-file local-file)) t
  "append to a file"
  (sendrequest "APPE" local-file remote-file))

(defcmd (cmd-get cmd-recv) (remote-file &optional (local-file remote-file)) t
  "receive one file"
  (recvrequest "RETR" local-file remote-file))

(defcmd (cmd-put cmd-send) (local-file &optional (remote-file local-file)) t
  "send one file"
  (sendrequest "STOR" local-file remote-file))

(defcmd (cmd-mput cmd-msend) (&rest local-files) t
  "send multiple files"
  (multiple-command "put" #'cmd-put local-files))

(defcmd (cmd-mget cmd-mrecv) (&rest remote-files) t
  "receive multiple files"
  (wildcard-multiple-command "get" #'cmd-get remote-files))

;;; connection hacking commands

(defcmd (cmd-quit cmd-bye) () nil
  "terminate ftp session and exit"
  (throw 'quit nil))

(defcmd cmd-open (to) nil
  "connect to remote ftp"
  (when (and *connected* *control* (send *control* :remote-address))
    (cmd-close))
  (hookup to "FTP")
  (when *auto-login*
    (cmd-user)))

(defun prompt-and-read-no-echo (prompt)
  (let ((line (make-string 30 :fill-pointer 0)))
    (loop
      (format *query-io* prompt)
      (loop
	(let ((char (send *query-io* :tyi)))
	  (cond ((= char #\c-q)			;quoting character.
		 (vector-push-extend (send *query-io* :tyi) line))
		((= char #\rubout)
		 (when (zerop (fill-pointer line))
		   (return))
		 (vector-pop line))
		((= char #\clear-input)
		 (return))
		((= char #\return)
		 (fresh-line *query-io*)
		 (send *query-io* :send-if-handles :make-complete)
		 (return-from prompt-and-read-no-echo
		   (when (> (fill-pointer line) 0)
		     line)))
		((/= 0 (char-bits char))
		 (global:beep))
		(t
		 (vector-push-extend char line)))))
      (format *query-io* "flushed.~&")
      (setf (fill-pointer line) 0))))

(defun send-user-info (prompt command arg &optional (echo? t))
  (declare (values string logged-in))
  (unless arg
    (setq arg (if echo?
		  (global:prompt-and-read :string-or-nil prompt)
		(prompt-and-read-no-echo prompt))))
  (when arg
    (let ((n (command "~A ~A" command arg)))
      (cond ((= n (sym continue))
	     (values arg nil))
	    ((= n (sym complete))
	     (values arg t))
	    (t
	     (values nil nil))))))

(defun try-login (u p a)
  (let ((logged-in nil)
	(tcp:*tcp-stream-whostate* "Login"))
    (multiple-value-setq (u logged-in)
      (send-user-info "(username) " "USER" u))
    (cond ((null u)
	   (return-from try-login))
	  (logged-in
	   (return-from try-login (values u p a))))

    (multiple-value-setq (p logged-in)
      (send-user-info "(password) " "PASS" p nil))
    (cond ((null p)
	   (return-from try-login))
	  (logged-in
	   (return-from try-login (values u p a))))

    (multiple-value-setq (a logged-in)
      (send-user-info "(account) " "ACCT" a))
    (cond ((null a)
	   (return-from try-login))
	  (logged-in
	   (return-from try-login (values u p a))))))

(defcmd cmd-user (&optional username password account) t
  "send new user information (login)"
  (cond ((multiple-value-setq (*user* *pass* *acct*)
	    (if username
		(try-login username password account)
	      (try-login *user* *pass* *acct*))))
	(*verbose*
	 (format t "~&Login failed~%"))))

(defcmd cmd-close () t
  "terminate ftp session"
  (when *connected*
    (setq *type* 'ascii)
    (setq *struct* 'file)
    (setq *form* 'non-print)
    (setq *mode* 'stream)
    (setq *bytesize* 8.)
    (close-data-connection)
    (command "QUIT")
    (close-control-connection)
    (setq *connected* nil)))

(defcmd cmd-quote (&rest command-line-to-send) t
  "send arbitrary ftp command"
  (= (sym complete) (command-1 (format nil "~{~A ~}" command-line-to-send))))

;;; help and status commands

(defcmd (cmd-help cmd-?) (&optional command) nil
  "print local help information"
  (let (last-entry
	alias-list)
    (fresh-line)
    (cond (command
	   (when (setq command (find-command-entry command))
	     (format t "~A:~A~%  ~A~%"
		     (car command)
		     (get (cdr command) :usage-string)
		     (or (documentation (cdr command))
			 ""))))
	  (t
	   (dolist (entry *ftp-command-alist*)
	     (when (and last-entry
			(not (eq (cdr entry) (cdr last-entry))))
	       (format t "*~{ ~A~^,~}:~A~%    ~A~%"
		       alias-list
		       (get (cdr last-entry) :usage-string)
		       (or (documentation (cdr last-entry)) ""))
	       (setq alias-list nil))
	     (push (car entry) alias-list)
	     (setq last-entry entry))))))

(defun onoff (bool)
  (if bool "ON" "OFF"))

(defcmd cmd-status () nil
  "show current status"
  (if *connected*
      (format t "~&Connected to ~A~%" *remote-hostname*)
    (format t "~&Not connected~%"))
  (format t "Mode: ~A; Type: ~A; Form: ~A; Structure: ~A~%"
	  *mode* *type* *form* *struct*)
  (format t "Verbose: ~A; Bell: ~A; Prompting: ~A; Globbing: ~A~
	   ~&Hash mark printing: ~A; Use of PORT cmds: ~A~%"
	  (onoff *verbose*)
	  (onoff *bell*)
	  (onoff *prompt*)
	  (onoff *glob*)
	  (onoff *hash*)
	  (onoff *sendport*)))

(defcmd cmd-remotehelp (&optional subject) t
  "get help from remote server"
  (let ((*verbose* t))
    (if subject
	(commandp (sym complete) "HELP ~A" subject)
      (commandp (sym complete) "HELP"))))

;;; type setting commands

(defmacro setopt (opt-sym opt &rest args)
  `(cond ((eq ,opt-sym ,opt) ,opt)
	 ((commandp (sym complete) (get ,opt :defopt-fmt) ,@args)
	  (setq ,opt-sym ,opt))
	 (t nil)))

(defcmd cmd-type (&optional type-name) nil
  "show/set file transfer type"
  (if type-name
      (execute-ftp-command-list `(,type-name))
    (format t "~&Using ~A type to transfer files.~%" *type*)))

(defcmd cmd-ascii () t
  "set ascii transfer type"
  (setopt *type* 'ascii))

(defcmd cmd-binary () t
  "set binary transfer type"
  (setopt *type* 'binary))

(defcmd cmd-image () t
  "set image transfer type"
  (setopt *type* 'image))

(defcmd cmd-ebcdic () t
  "set ebcdic transfer type"
  (setopt *type* 'ebcdic))

(defcmd cmd-tenex () t
  "set tenex transfer type"
  (setopt *type* 'tenex *bytesize*))

(defcmd cmd-16bit () t
  "set 16bit transfer type"
  (unless (setopt *type* '16bit)
    (setopt *type* 'binary)))

;;; format setting commands

(defcmd cmd-form (&optional format-name) nil
  "show/set file transfer format"
  (if format-name
      (execute-ftp-command-list `(,format-name))
    (format t "~&Using ~A format to transfer files.~%" *form*)))

(defcmd cmd-non-print () t
  "set non-print transfer format"
  (setopt *form* 'non-print))

(defcmd cmd-telnet () t
  "set telnet transfer format"
  (setopt *form* 'telnet))

(defcmd cmd-carriage-control () t
  "set carriage-control transfer format"
  (setopt *form* 'carriage-control))

;;; struct setting commands

(defcmd cmd-struct (&optional structure-name) nil
  "show/set file transfer structure"
  (if structure-name
      (execute-ftp-command-list `(,structure-name))
    (format t "~&Using ~A structure to transfer files.~%" *struct*)))

(defcmd cmd-file () t
  "set file transfer structure"
  (setopt *struct* 'file))

(defcmd cmd-record () t
  "set record transfer structure"
  (setopt *struct* 'record))

(defcmd cmd-page () t
  "set page transfer structure"
  (setopt *struct* 'page))

;;; mode setting commands
 
(defcmd cmd-mode (&optional mode-name) nil
  "show/set file transfer mode"
  (if mode-name
      (execute-ftp-command-list `(,mode-name))
    (format t "~&Using ~A mode to transfer files.~%" *mode*)))

(defcmd cmd-stream () t
  "set stream transfer mode"
  (setopt *mode* 'stream))

(defcmd cmd-block () t
  "set block transfer mode"
  (setopt *mode* 'block))

(defcmd cmd-compressed () t
  "set compressed transfer mode"
  (setopt *mode* 'compressed))

;;; toggle setting commands

(defcmd cmd-bell () nil
  "beep when command completed"
  (setq *bell* (not *bell*))
  (format t "~&Bell mode ~A.~%" (onoff *bell*)))

(defcmd cmd-debug () nil
  "toggle debugging mode"
  (setq *debug* (not *debug*))
  (format t "~&Debugging ~A.~%" (onoff *debug*)))

(defcmd cmd-glob () nil
  "toggle metacharacter expansion of local file names"
  (setq *glob* (not *glob*))
  (format t "~&Globbing ~A.~%" (onoff *glob*)))

(defcmd cmd-trace () nil
  "toggle packet tracing"
  (setq *trace* (not *trace*))
  (format t "~&Packet tracing ~A.~%" (onoff *trace*)))

(defcmd cmd-hash () nil
  "toggle printing `#' for each buffer transferred"
  (setq *hash* (not *hash*))
  (format t "~&Hash mark printing ~A.~%" (onoff *hash*)))

(defcmd cmd-verbose () nil
  "toggle verbose mode"
  (setq *verbose* (not *verbose*))
  (format t "~&Verbose mode ~A.~%" (onoff *verbose*)))

(defcmd cmd-prompt () nil
  "toggle interactive confirmation on `multiple' commands"
  (setq *prompt* (not *prompt*))
  (format t "~&Interactive mode ~A.~%" (onoff *prompt*)))

(defcmd cmd-sendport () nil
  "toggle use of PORT cmd use before each data connection"
  (setq *sendport* (not *sendport*))
  (format t "~&Use of PORT cmds ~A.~%" (onoff *sendport*)))

;;; directory hacking commands

(defcmd cmd-cd (&optional remote-directory) t
  "change remote working directory"
  (commandp (sym complete) "CWD~@[ ~A~]" remote-directory))

(defcmd cmd-lcd (&optional local-directory) nil
  "change local working directory"
  (let* ((defaults (fs:make-pathname-defaults))
	 (colon (string-search-char #\: local-directory))
	 (host (if colon (si:parse-host (substring local-directory 0 colon) t) si:local-host))
	 (pathname (fs:parse-pathname local-directory host defaults (if colon (1+ colon) 0))))
    (fs:set-default-pathname pathname)
    (format t "~&Local pathname default now ~A.~%" pathname)
    pathname))

(defcmd (cmd-ls cmd-dir) (&optional remote-directory local-file) t
  "list contents of remote directory"
  (cmd-ascii)
  (recvrequest "LIST" local-file remote-directory))

(defcmd (cmd-nlst) (&optional remote-directory local-file) t
  "list contents of remote directory"
  (cmd-ascii)
  (recvrequest "NLST" local-file remote-directory))

(defcmd (cmd-mls cmd-mdir) (&rest remote-directories) t
  "list contents of multiple remote directories"
  (multiple-command "ls" #'cmd-ls remote-directories))
	    
(defcmd cmd-pwd () t
  "print working directory on remote machine"
  (or (commandp (sym complete) "PWD")
      (commandp (sym complete) "XPWD")))

(defcmd cmd-mkdir (remote-directory) t
   "make a directory on remote machine"
   (or (commandp (sym complete) "MKD ~A" remote-directory)
       (commandp (sym complete) "XMKD ~A" remote-directory)))

(defcmd cmd-rmdir (remote-directory) t
  "remove a directory on remote machine"
  (or (commandp (sym complete) "RMD ~A" remote-directory)
      (commandp (sym complete) "XRMD ~A" remote-directory)))

(defcmd cmd-expunge (remote-directory) t
  "expunge the contents of a directory"
  (when (commandp (sym complete) "XPNG ~A" remote-directory)
    (let* ((reply (last-reply))
	   (start (and reply (string-search-char #\space reply)))
	   (blocks-freed (and start (parse-integer reply :start start :junk-allowed t))))
      (or blocks-freed 0))))

;;; file hacking commands

(defcmd cmd-delete (remote-file) t
  "delete one remote file"
  (commandp (sym complete) "DELE ~A" remote-file))

(defcmd cmd-undelete (remote-file) t
  "undelete one remote file"
  (commandp (sym complete) "XUND ~A" remote-file))

(defcmd cmd-mdelete (&rest remote-files) t
  "delete multiple remote files"
  (wildcard-multiple-command "delete" #'cmd-delete remote-files))

(defcmd cmd-rename (from-name to-name) t
  "rename a remote file"
  (when (member (command "RNFR ~A" from-name)
		;; vms file server has bug, sends complete.
		(list (sym continue) (sym complete))
		:test #'eq)
    (commandp (sym complete) "RNTO ~A" to-name)))

(defun push-history ()
  (cond ((eq *history* :dont-record)
	 (if *last-reply*
	     (setf (fill-pointer *last-reply*) 0)
	   (setq *last-reply* (make-history-element))))
	(t
	 (push (setq *last-reply* (make-history-element)) *history*))))

(defun make-history-element ()
  (make-array 80. :element-type 'string-char :fill-pointer 0 :adjustable t))

(defun history-record-char (c)
  (vector-push-extend c *last-reply*))

(defun last-reply ()
  *last-reply*)

(defun history-record-stream (op &optional arg1 &rest args)
  (si:selectq-with-which-operations op
    (:tyo (history-record-char arg1))
    (t
      (global:stream-default-handler #'history-record-stream op arg1 args))))

(defcmd cmd-history () nil
  "print command/reply history"
  (when (not (eq *history* :dont-record))
    (dolist (x (reverse *history*))
      (fresh-line)
      (princ x))))


(export 'ftp)
(defun ftp (&optional remote-hostname
	    &key
	    (auto-login t)
	    (trace t)
	    (hash nil)
	    (sendport t)
	    (verbose t)
	    (debug nil)
	    (bell nil)
	    (glob t)
	    (prompt t)
	    user pass acct)

  (let ((*print-radix* nil)			;prevent garbage in numbers printed by princ and prin1
	(*history* nil)
	(*last-reply* nil)
	(*connected* nil)
	(*control* nil)
	(*data* nil)
	(*remote-hostname* remote-hostname)
	(*auto-login* auto-login)
	(*trace* trace)
	(*hash* hash)
	(*sendport* sendport)
	(*verbose* verbose)
	(*debug* debug)
	(*bell* bell)
	(*glob* glob)
	(*prompt* prompt)
	(*type* 'ascii)
	(*struct* 'file)
	(*form* 'non-print)
	(*mode* 'stream)
	(*bytesize* 8.)
	(*user* user)
	(*pass* pass)
	(*acct* acct))

    (when *remote-hostname*
      (cmd-open *remote-hostname*))
    (catch 'quit
      (unwind-protect
	  (global:error-restart ((error) "Return to FTP Command Loop.")
	    (loop
	      (execute-ftp-command-list
		(parse-line-into-list
		  (global:prompt-and-read :string-or-nil "~&ftp> ")))))
	(cmd-close)))))
