(use-modules (srfi srfi-1)
             (srfi srfi-13)
             (ice-9 popen)
             (ice-9 rdelim)
             (ice-9 match))

(define (run-backtrace-script file)
  (let* ((pipe (open-pipe (string-append "./test-env guile " file " 2>&1")
                          OPEN_READ))
         (output (read-string pipe)))
    (close-pipe pipe)
    output))

(define (read-backtrace-entry-annotation script keyword)
  ;; Scan SCRIPT line by line and return the annotation for the expected
  ;; backtrace entry matching KEYWORD (e.g. "FIRST" or "LAST"), or #f if
  ;; none is found.
  ;;
  ;; Two forms are recognised:
  ;;
  ;;   ; KEYWORD BACKTRACE ENTRY HERE
  ;;     — placed inline on a code line.  Returns ('here LINE COL) where LINE
  ;;       is the 1-based line number and COL is the 0-based column of the
  ;;       first non-space character on that line.
  ;;
  ;;   ; KEYWORD BACKTRACE ENTRY: STRING
  ;;     — STRING is a literal substring expected to appear in the output.
  ;;       Returns ('string STRING).
  (let ((here-marker (string-append keyword " BACKTRACE ENTRY HERE"))
        (string-marker (string-append keyword " BACKTRACE ENTRY: ")))
    (call-with-input-file script
      (lambda (port)
        (let loop ((line (read-line port)) (line-num 1))
          (cond
            ((eof-object? line) #f)
            ((string-contains line here-marker)
             (let ((col (string-index line (lambda (c) (not (char=? c #\space))))))
               (list 'here line-num col)))
            ((string-contains line string-marker)
             (let* ((idx (+ (string-contains line string-marker)
                            (string-length string-marker)))
                    (content (string-trim-right (substring line idx))))
               (list 'string content)))
            (else (loop (read-line port) (+ line-num 1)))))))))

(define (frame-line? line)
  ;; Return #t if LINE looks like a backtrace frame line: leading whitespace
  ;; followed by digits:digits (LINE:COL).
  (and (> (string-length line) 0)
       (let* ((stripped (string-trim line))
              (colon    (string-index stripped #\:)))
         (and colon
              (> colon 0)
              (string-every char-set:digit stripped 0 colon)))))

(define (extract-frame-lines output)
  ;; Return all backtrace frame lines before "ERROR:" in OUTPUT.
  (let* ((error-pos (string-contains output "\nERROR:"))
         (before-error (if error-pos
                           (substring output 0 error-pos)
                           output)))
    (filter frame-line? (string-split before-error #\newline))))

(define (innermost-frame-line output)
  ;; Return the last backtrace frame line before "ERROR:" in OUTPUT, or #f.
  (let ((frame-lines (extract-frame-lines output)))
    (if (null? frame-lines) #f (last frame-lines))))

(define (outermost-frame-line output)
  ;; Return the first backtrace frame line before "ERROR:" in OUTPUT, or #f.
  (let ((frame-lines (extract-frame-lines output)))
    (if (null? frame-lines) #f (car frame-lines))))

;;; Assertions

(define current-test-fail-count 0)

(define (expect! label ok? detail)
  ;; Print one expectation line; record a failure if not ok.
  (if ok?
      (format #t "  PASS  ~a~%" label)
      (begin
        (set! current-test-fail-count (+ current-test-fail-count 1))
        (format #t "  FAIL  ~a~%        ~a~%" label detail))))

(define (assert-output-contains output expected)
  (expect! (format #f "output contains ~s" expected)
           (string-contains output expected)
           "not found in output"))

(define (assert-output-excludes output unexpected)
  (expect! (format #f "output excludes ~s" unexpected)
           (not (string-contains output unexpected))
           "unexpectedly found in output"))

(define (assert-backtrace-entry output script keyword frame-line-proc)
  (let ((annotation (read-backtrace-entry-annotation script keyword))
        (frame (frame-line-proc output)))
    (when annotation
      (match annotation
        (('here line col)
         (let ((expected (string-append (number->string line) ":"
                                        (number->string col))))
           (expect! (format #f "~a backtrace entry ~a" keyword expected)
                    (and frame (string-contains frame expected))
                    (format #f "got ~s" (or frame "(none)")))))
        (('string content)
         (expect! (format #f "~a backtrace entry ~s" keyword content)
                  (string-contains output content)
                  "not found in output"))))))

(define (assert-first-backtrace-entry output script)
  (assert-backtrace-entry output script "FIRST" outermost-frame-line))

(define (assert-last-backtrace-entry output script)
  (assert-backtrace-entry output script "LAST" innermost-frame-line))

;;; Test runner

(define pass-count 0)
(define fail-count 0)

(define (run-test name thunk)
  (set! current-test-fail-count 0)
  (format #t "~%~a~%" name)
  (catch #t
    thunk
    (lambda (key . args)
      (set! current-test-fail-count (+ current-test-fail-count 1))
      (format #t "  ERROR  unexpected exception: ~s~%" (cons key args))))
  (if (zero? current-test-fail-count)
      (set! pass-count (+ pass-count 1))
      (set! fail-count (+ fail-count 1))))

;;; Tests

(run-test "plain-exception"
  (lambda ()
    (let* ((script "tests/backtraces/plain-exception.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"plain error message\""))))

(run-test "triple-with-exception-handler"
  (lambda ()
    (let* ((script "tests/backtraces/triple-with-exception-handler.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"plain error message\""))))

(run-test "wrapped-exception"
  (lambda ()
    (let* ((script "tests/backtraces/wrapped-exception.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"wrapped error message\""))))

(run-test "temporary-thread"
  (lambda ()
    (let* ((script "tests/backtraces/temporary-thread.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from temporary thread\""))))

(run-test "fibers-map"
  (lambda ()
    (let* ((script "tests/backtraces/fibers-map.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from fibers-map\""))))

(run-test "call-with-resource-from-pool"
  (lambda ()
    (let* ((script "tests/backtraces/call-with-resource-from-pool.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from call-with-resource-from-pool\""))))

;; Two knots stacks are printed (one per fiber boundary); ERROR: appears
;; once at the end after both frame blocks.
(run-test "call-with-cached-connection"
  (lambda ()
    (let* ((script "tests/backtraces/call-with-cached-connection.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from call-with-cached-connection\""))))

(run-test "fibers-force"
  (lambda ()
    (let* ((script "tests/backtraces/fibers-force.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from fibers-force\""))))

(run-test "call-with-thread"
  (lambda ()
    (let* ((script "tests/backtraces/call-with-thread.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"error from call-with-thread\""))))

;; Nested fibers-map: user frames that survive fiber boundaries appear;
;; intermediate functions (one-deep, two-deep, three-deep) are lost at
;; their respective boundaries because fibers-map yields before the
;; exception propagates back.  knots/parallelism.scm and srfi frames
;; appear as call-path context between the surviving user frames.
(run-test "nested-parallelism"
  (lambda ()
    (let* ((script "tests/backtraces/nested-parallelism.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &error\n  2. &origin: #f\n  3. &message: \"deeply nested error ~S\"")
      (assert-output-contains output "(run-work)")
      (assert-output-contains output "(process-batch _)")
      (assert-output-contains output "(deeply-nested _)")
      (assert-output-excludes output "In fibers"))))

(run-test "guile-error-in-thread"
  (lambda ()
    (let* ((script "tests/backtraces/guile-error-in-thread.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &assertion-failure\n  2. &origin: \"+\"\n  3. &message: \"Wrong type argument in position ~A: ~S\"\n  4. &irritants: (1 a)"))))

;; sort is a C function and appears as "In unknown file:" between the user frames.
(run-test "guile-error-deep-in-thread"
  (lambda ()
    (let* ((script "tests/backtraces/guile-error-deep-in-thread.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &assertion-failure\n  2. &origin: \"+\"")
      (assert-output-contains output "(do-sort)")
      (assert-output-contains output "In unknown file:")
      (assert-output-contains output "(sort (1 2 3)")
      (assert-output-excludes output "In knots/")
      (assert-output-excludes output "In srfi/"))))

;; The error fires inside ice-9/vlist.scm (vlist-fold passed a non-vlist),
;; so vlist-fold appears as the innermost frame and ice-9/vlist.scm frames
;; appear between the user frames.
(run-test "vhash-fold"
  (lambda ()
    (let* ((script "tests/backtraces/vhash-fold.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &assertion-failure\n  2. &origin: #f")
      (assert-output-contains output "(do-fold)")
      (assert-output-contains output "In ice-9/vlist.scm:")
      (assert-output-contains output "(vlist-fold"))))

;; do-fold calls vhash-fold in non-tail position so its frame is preserved.
;; ice-9/vlist.scm frames appear between the user frames, as in vhash-fold.
(run-test "vhash-fold-in-thread"
  (lambda ()
    (let* ((script "tests/backtraces/vhash-fold-in-thread.scm")
           (output (run-backtrace-script script)))
      (assert-first-backtrace-entry output script)
      (assert-last-backtrace-entry output script)
      (assert-output-contains output
        "ERROR:\n  1. &assertion-failure\n  2. &origin: #f")
      (assert-output-contains output "(do-fold)")
      (assert-output-contains output "In ice-9/vlist.scm:")
      (assert-output-contains output "(vlist-fold")
      (assert-output-excludes output "In knots/"))))

(run-test "stack-situation-script"
  (lambda ()
    (let* ((script "tests/backtraces/stack-situation-script.scm")
           (output (run-backtrace-script script)))
      (assert-output-contains output "situation: script"))))

(run-test "stack-situation-fibers"
  (lambda ()
    (let* ((script "tests/backtraces/stack-situation-fibers.scm")
           (output (run-backtrace-script script)))
      (assert-output-contains output "situation: run-fibers"))))

(run-test "stack-situation-unknown"
  (lambda ()
    (let* ((script "tests/backtraces/stack-situation-unknown.scm")
           (output (run-backtrace-script script)))
      (assert-output-contains output "situation: unknown"))))

;;; Summary

(newline)
(if (zero? fail-count)
    (format #t "All ~a scripts passed.~%" pass-count)
    (format #t "~a of ~a scripts had failures.~%" fail-count (+ pass-count fail-count)))

(when (> fail-count 0)
  (primitive-exit 1))
