(module util mzscheme
  
  (require "config.ss"
           "planet-archives.ss"
           
           "private/planet-shared.ss"
           "private/linkage.ss"
           "resolver.ss"
           (lib "url.ss" "net")
           (lib "xml.ss" "xml")
           (lib "contract.ss")
           (lib "file.ss")
           (lib "list.ss")
           (lib "pack.ss" "setup")
           (lib "plt-single-installer.ss" "setup")
           (lib "getinfo.ss" "setup"))

  #| The util collection provides a number of useful functions for interacting with the PLaneT system. |#
  
  (provide 
   
   current-cache-contents
   current-linkage
   make-planet-archive
   force-package-building?
   get-installed-planet-archives
   get-hard-linked-packages
   remove-pkg
   unlink-all
   lookup-package-by-keys
   
   resolve-planet-path)
  
  (provide/contract
   [download/install-pkg
    (-> string? string? natural-number/c natural-number/c (union pkg? false/c))]
   [add-hard-link 
    (-> string? string? natural-number/c natural-number/c path? void?)]
   [remove-hard-link 
    (-> string? string? natural-number/c natural-number/c void?)]
   [erase-pkg
    (-> string? string? natural-number/c natural-number/c boolean?)])

  ;; download/install-pkg : string string nat nat -> pkg | #f
  (define (download/install-pkg owner name maj min)
    (let* ([pspec (pkg-spec->full-pkg-spec (list owner name maj min) #f)]
           [upkg (get-package-from-server pspec)])
      (cond
        [(uninstalled-pkg? upkg)
         (pkg-promise->pkg upkg)]
        [else #f])))
  
  ;; current-cache-contents : -> ((string ((string ((nat (nat ...)) ...)) ...)) ...)
  ;; returns the packages installed in the local PLaneT cache
  (define (current-cache-contents)
    (cdr (tree->list (repository-tree))))
  
  ;; just so it will be provided
  (define unlink-all remove-all-linkage!)
  
  ;; to remove:
  ;;   -- setup-plt -c the package
  ;;   -- remove relevant infodomain cache entries
  ;;   -- delete files from cache directory
  ;;   -- remove any existing linkage for package
  ;; returns #t if the removal worked; #f if no package existed.
  (define (remove-pkg owner name maj min)
    (let ((p (get-installed-package owner name maj min)))
      (and p
           (let ((path (pkg-path p)))
             (with-logging 
              (LOG-FILE)
              (lambda () 
                (printf "\n============= Removing ~a =============\n" (list owner name maj min))
                (clean-planet-package path (list owner name '() maj min))))
             (erase-metadata p)
             (delete-directory/files path)
             (trim-directory (CACHE-DIR) path)
             #t))))
  
  ;; erase-metadata : pkg -> void
  ;; clears out any references to the given package in planet's metadata files
  ;; (i.e., linkage and info.ss cache; not hard links which are not considered metadata)
  (define (erase-metadata p)
    (remove-infodomain-entries (pkg-path p))
    (remove-linkage-to! p))
    
  ;; this really should go somewhere else. But what should setup's behavior be
  ;; when a package is cleaned? should it clear info-domain entries out? I think
  ;; no; an uncompiled package isn't necessarily not to be indexed and so on.
  ;; remove-infodomain-entries : path -> void
  (define (remove-infodomain-entries path)
    (let* ([pathbytes (path->bytes path)]
           [cache-file (build-path (PLANET-DIR) "cache.ss")])
      (when (file-exists? cache-file)
        (let ([cache-lines (with-input-from-file cache-file read)])
          (with-output-to-file cache-file
            (lambda ()
              (if (pair? cache-lines)
                  (write (filter (lambda (line) (not (and (pair? line) (equal? (car line) pathbytes)))) cache-lines))
                  (printf "\n")))
            'truncate/replace)))))
  
  (define (erase-pkg owner name maj min)
    (let* ([uninstalled-pkg-dir
            (build-path (UNINSTALLED-PACKAGE-CACHE) owner name (number->string maj) (number->string min))]
           [uninstalled-pkg-file (build-path uninstalled-pkg-dir name)])
      (let ([removed-something? (remove-pkg owner name maj min)]
            [erased-something?
             (if (file-exists? uninstalled-pkg-file)
                 (begin
                   (delete-file uninstalled-pkg-file)
                   (trim-directory (UNINSTALLED-PACKAGE-CACHE) uninstalled-pkg-dir)
                   #t)
                 #f)])
        (or removed-something? erased-something?))))

  ;; listof X * listof X -> nonempty listof X
  ;; returns de-prefixed version of l2 if l1 is a proper prefix of l2; 
  ;; signals an error otherwise.
  (define (drop-common-base list1 list2)
    (let loop ((l1 list1) (l2 list2))
      (cond
        [(null? l2)
         (error 'drop-common-base "root ~s is not a prefix of stem ~s" list1 list2)]
        [(null? l1) l2]
        [(not (equal? (car l1) (car l2)))
         (error 'drop-common-base "root ~s is not a prefix of stem ~s" list1 list2)]
        [else (loop (cdr l1) (cdr l2))])))
  
  ;; pathify-list : path (listof path) -> listof path
  ;; given a base and a list of names, interprets each name as a subdirectory
  ;; of the previous, starting with base, and returns a list. (This list
  ;; is in reverse order, so the deepest subdirectory is returned first)
  (define (pathify-list root dirs)
    (let loop ((base root) (dirs dirs) (acc '()))
      (cond
        [(null? dirs) acc]
        [else
         (let ((new (build-path base (car dirs))))
           (loop new (cdr dirs) (cons new acc)))])))
  
  ;; directory-empty? path -> bool
  ;; #t iff the given directory contains no subdirectories of files
  (define (directory-empty? dir)
    (null? (directory-list dir)))
  
  ;; trim-directory path path -> void
  ;; deletes nonempty directories starting with stem and working down to root
  (define (trim-directory root stem)
    (let* ([rootl (explode-path root)]
           [steml (explode-path stem)]
           [extras (cdr (pathify-list root (drop-common-base rootl steml)))])
      (let loop ((dirs extras))
        (cond
          [(null? dirs) (void)]
          [(directory-empty? (car dirs))
           (delete-directory (car dirs))
           (loop (cdr dirs))]
          [else (void)]))))

  ;; current-linkage : -> ((symbol (package-name nat nat) ...) ...)
  ;; gives the current "linkage table"; a table that links modules to particular versions
  ;; of planet requires that satisfy those linkages
  (define (current-linkage)
    (let* ((links 
            (if (file-exists? (LINKAGE-FILE))
                (with-input-from-file (LINKAGE-FILE) read-all)
                '()))
           (buckets (categorize caar links)))
      (map
       (lambda (x) (cons (car x) (map (lambda (y) (drop-last (cadr y))) (cdr x))))
       buckets)))
  
  ;; regexp->filter : (string | regexp) -> (path -> bool)
  ;; computes a filter that accepts paths that match the given regexps and rejects other paths
  (define (regexp->filter re-s)
    (let ([re (cond
                [(string? re-s) (regexp re-s)]
                [(regexp? re-s) re-s]
                [else (error 'regexp->filter "not a regular expression")])])
      (lambda (p) (regexp-match re (path->bytes p)))))
  
  (define force-package-building? (make-parameter #f))
  
  ;; make-planet-archive: directory [file] -> file
  ;; Makes a .plt archive file suitable for PLaneT whose contents are
  ;; all files in the given directory and returns that file's name.
  ;; If the optional filename argument is provided, that filename will 
  ;; be used as the output file's name.
  (define make-planet-archive
    (case-lambda
      [(dir) 
       (let-values ([(path name must-be-dir?) (split-path dir)])
         (make-planet-archive 
          dir 
          (build-path (normalize-path (current-directory))
                      (string-append (path->string name) ".plt"))))]
      [(dir archive-name)
       (parameterize ((current-directory dir))
         (let ([announcements '()]
               [warnings '()]
               [critical-errors '()])
           (check-info.ss-sanity 
            dir
            (λ (msg . args) (set! announcements (cons (apply format msg args) announcements)))
            (λ (bad) (set! warnings (cons bad warnings)))
            (λ (err) (set! critical-errors (cons err critical-errors))))
           
           (unless 
               (or (null? critical-errors)
                   (force-package-building?))
             (error '|PLaneT packager| "~a Refusing to continue packaging." (car critical-errors)))
           
           (pack archive-name
                 "archive" 
                 (list ".")
                 null
                 (if (PLANET-ARCHIVE-FILTER)
                     (regexp->filter (PLANET-ARCHIVE-FILTER))
                     std-filter)
                 #t
                 'file
                 #f
                 #f)
         
           (for-each display (reverse announcements))
           (newline)
           (for-each
            (λ (s) (fprintf (current-error-port) "WARNING:\n\t~a\n" s))
            (reverse warnings)))
         
         (normalize-path archive-name))]))
 
  ;; check-info.ss-sanity : path (string -> void) (string -> void) (string -> void) -> void
  ;; gets all the info.ss fields that planet will use (using the info.ss file
  ;; from the current directory) and calls the announce, warn, and fail functions with strings
  ;; that describe how PLaneT sees the info.ss file. NOTA BENE: if this function calls fail, it may 
  ;; also warn on the same field, and the warning may not make sense. This is based on the
  ;; assumption that errors will be turned into some kind of critical failure that obliterates
  ;; all the other information produced.
  (define (check-info.ss-sanity dir announce warn fail)
    (with-handlers ([exn:fail:read? 
                     (λ (e) (fail (format "Package has an unreadable info.ss file. ~a" (exn-message e))))]
                    [exn:fail:syntax? 
                     (λ (e) (fail (format "Package's info.ss has an syntactically ill-formed info.ss file: ~a" (exn-message e))))]) 
      (let ([i* (get-info/full dir)])
        (cond
          [(not i*) 
           (warn "Package has no info.ss file. This means it will not have a description or documentation on the PLaneT web site.")]
          [else
           (let ([i (λ (field) (i* field (λ () #f)))])
             (checkinfo i fail
                        [name     ; field name 
                         string?  ; check
                         (announce "Name: ~a\n" name)  ; success action
                         (warn "Package's info.ss file has no name field. Without a name, PLT Scheme will not compile your package.") ;failure action
                         ]
                        [blurb 
                         (λ (b) (and (list? b) (andmap xexpr? b)))
                         (announce "Package blurb: ~s\n" blurb)
                         (unless blurb
                           (warn "Package's info.ss does not contain a blurb field. Without a blurb field, the package will have no description on planet.plt-scheme.org."))]
                        [release-notes 
                         (λ (b) (and (list? b) (andmap xexpr? b)))
                         (announce "Release notes: ~s\n" release-notes)
                         (unless release-notes
                           (warn "Package's info.ss does not contain a release-notes field. Without a release-notes field, the package will not have any listed release information on planet.plt-scheme.org beyond the contents of the blurb field."))]
                        [categories
                         (λ (s) (and (list? s) (andmap symbol? s)))
                         (cond
                           [(ormap illegal-category categories)
                            =>
                            (λ (bad-cat)
                              (fail (format "Package's info.ss file contains illegal category \"~a\". The legal categories are: ~a\n" 
                                            bad-cat
                                            legal-categories)))]
                           [else (announce "Categories: ~a\n" categories)])
                         (unless categories
                           (warn "Package's info.ss file does not contain a category listing. It will be placed in the Miscellaneous category."))]
                        [doc.txt
                         string?
                         (announce "doc.txt file: ~a\n" doc.txt)
                         (unless doc.txt
                           (warn "Package's info.ss does not contain a doc.txt entry. Without a doc.txt entry, the package will not have any documentation on planet.plt-scheme.org."))]
                        [html-docs
                         (lambda (s) (and (list? s) (andmap string? s)))
                         (announce "HTML documentation: yes\n")]
                        [homepage 
                         string?
                         (cond
                           [(url-string? homepage)
                            (announce "Home page: ~a\n" homepage)]
                           [else
                            (fail (format "The value of the package's info.ss homepage field, ~s, does not appear to be a legal URL." homepage))])]
                        [primary-file
                         (λ (x) (or (string? x) (and (list? x) (andmap string? x))))
                         (begin
                           (cond
                             [(string? primary-file) 
                              (unless (file-in-current-directory? primary-file)
                                (warn (format "Package's info.ss primary-file field is ~s, a file that does not exist in the package." 
                                              primary-file)))]
                             [(pair? primary-file)
                              (let ([bad-files (filter (λ (f) (not (file-in-current-directory? f))) primary-file)])
                                (unless (null? bad-files)
                                  (warn (format "Package's info.ss primary-file field is ~s, which contains non-existant files ~s."
                                                primary-file bad-files))))])
                           (announce "Primary file: ~a\n" primary-file))
                         (unless primary-file
                           (warn "Package's info.ss does not contain a primary-file field. The package's listing on planet.plt-scheme.org will not have a valid require line for your package."))]
                        [required-core-version 
                         core-version?
                         (announce "Required mzscheme version: ~a\n" required-core-version)]
                        [version
                         string?
                         (announce "Version description: ~a\n" version)]))]))))
  
  ;; legal-categories : (listof symbol)
  (define legal-categories
    '(devtools net media xml datastructures io scientific
      system ui metaprogramming planet misc))

  ;; legal-category : symbol -> boolean
  ;; determine if the given symbol is a legal category
  (define (legal-category? x) (memq x legal-categories))
  
  ;; illegal-category : symbol -> (union symbol false)
  ;; returns #f if the symbol is a legal category, or the symbol itself if it isn't
  (define (illegal-category s) (if (legal-category? s) #f s)) 

  ;; url-string? : string -> boolean
  ;; determines if the given string is a reasonable homepage url
  (define (url-string? s)
    (and (string? s)
         (let ([u (string->url s)])
           (and (url-scheme u)
                (url-host u)))))
  
  ;; file-in-current-directory? : string -> boolean
  ;; determines if the given string represents a file in the current directory
  (define (file-in-current-directory? f)
    (and (string? f) (file-exists? f)))
  
  ;; core-version : string -> boolean
  ;; determines if the given string is something that (version) could've produced
  (define (core-version? s)
    (and (string? s)
         (regexp-match #rx"^[0-9]+(\\.[0-9]*)?$" s)))
            
  ;; checkinfo: syntax
  ;; given an info.ss function, a failure function, and a bunch of fields to check,
  ;; goes through the checklist calling either the success or the failure branch
  ;; of each check as appropriate
  (define-syntax checkinfo
    (syntax-rules ()
      [(checkinfo fn fail clauses ...)
       (let ([fn* fn] [fail* fail])
         (checkinfo* () fn* fail* clauses ...))]))
  
  (define-syntax checkinfo*
    (syntax-rules ()
      [(checkinfo* () fn fail) (void)]
      [(checkinfo* (handler1 handler ...) fn fail) (begin handler1 handler ...)]
      [(checkinfo* (handler ...) fn fail [id check on-success] clauses ...)
       (checkinfo* (handler ...) fn fail [id check on-success void] clauses ...)]
      [(checkinfo* (handler ...) fn fail [id check on-success on-fail] clauses ...)
       (checkinfo*
        (handler ...
         (let ([id (fn 'id)])
           (cond
             [id
              (let ([checked (check id)])
                (unless checked
                  on-fail
                  (fail (format "Package's info.ss contained a malformed ~a field." 'id)))
                on-success)]
             [else on-fail])))
         fn fail clauses ...)]))
  
  ;; ============================================================
  ;; HARD LINKS (aka development links)
  
  ;; add-hard-link : string string num num path -> void
  ;; adds an entry in the hard-links table associating the given
  ;; require spec to the given path
  (define (add-hard-link owner pkg-name maj min path)
    (unless (directory-exists? path)
      (if (file-exists? path)
          (error 'add-hard-link "Hard links must point to directories, not files")
          (fprintf (current-error-port) 
                   "Warning: directory ~a does not exist\n"
                   (path->string path))))
    (add-hard-link! pkg-name (list owner) maj min path))
  
  ;; remove-hard-link : string string num num -> void
  ;; removes any development association from the given package spec
  (define (remove-hard-link owner pkg-name maj min)
    (filter-link-table!
     (lambda (row) 
       (not (points-to? row pkg-name (list owner) maj min)))
     (lambda (row) 
       (let ([p (row->package row)])
         (when p
           (erase-metadata p)))))))
