;; lib/datalog/tests/aggregates.sx — count / sum / min / max.

(define dl-at-pass 0)
(define dl-at-fail 0)
(define dl-at-failures (list))

(define
  dl-at-deep=?
  (fn
    (a b)
    (cond
      ((and (list? a) (list? b))
        (and (= (len a) (len b)) (dl-at-deq-l? a b 0)))
      ((and (dict? a) (dict? b))
        (let ((ka (keys a)) (kb (keys b)))
          (and (= (len ka) (len kb)) (dl-at-deq-d? a b ka 0))))
      ((and (number? a) (number? b)) (= a b))
      (else (equal? a b)))))

(define
  dl-at-deq-l?
  (fn
    (a b i)
    (cond
      ((>= i (len a)) true)
      ((not (dl-at-deep=? (nth a i) (nth b i))) false)
      (else (dl-at-deq-l? a b (+ i 1))))))

(define
  dl-at-deq-d?
  (fn
    (a b ka i)
    (cond
      ((>= i (len ka)) true)
      ((let ((k (nth ka i)))
         (not (dl-at-deep=? (get a k) (get b k))))
        false)
      (else (dl-at-deq-d? a b ka (+ i 1))))))

(define
  dl-at-set=?
  (fn
    (a b)
    (and
      (= (len a) (len b))
      (dl-at-subset? a b)
      (dl-at-subset? b a))))

(define
  dl-at-subset?
  (fn
    (xs ys)
    (cond
      ((= (len xs) 0) true)
      ((not (dl-at-contains? ys (first xs))) false)
      (else (dl-at-subset? (rest xs) ys)))))

(define
  dl-at-contains?
  (fn
    (xs target)
    (cond
      ((= (len xs) 0) false)
      ((dl-at-deep=? (first xs) target) true)
      (else (dl-at-contains? (rest xs) target)))))

(define
  dl-at-test!
  (fn
    (name got expected)
    (if
      (dl-at-deep=? got expected)
      (set! dl-at-pass (+ dl-at-pass 1))
      (do
        (set! dl-at-fail (+ dl-at-fail 1))
        (append!
          dl-at-failures
          (str
            name
            "\n    expected: " expected
            "\n    got:      " got))))))

(define
  dl-at-test-set!
  (fn
    (name got expected)
    (if
      (dl-at-set=? got expected)
      (set! dl-at-pass (+ dl-at-pass 1))
      (do
        (set! dl-at-fail (+ dl-at-fail 1))
        (append!
          dl-at-failures
          (str
            name
            "\n    expected (set): " expected
            "\n    got:            " got))))))

(define
  dl-at-throws?
  (fn
    (thunk)
    (let
      ((threw false))
      (do
        (guard
          (e (#t (set! threw true)))
          (thunk))
        threw))))

(define
  dl-at-run-all!
  (fn
    ()
    (do
      ;; count
      (dl-at-test-set! "count siblings"
        (dl-query
          (dl-program
            "parent(p, bob). parent(p, alice). parent(p, charlie).
             sibling(X, Y) :- parent(P, X), parent(P, Y), !=(X, Y).
             sib_count(N) :- count(N, S, sibling(bob, S)).")
          (list (quote sib_count) (quote N)))
        (list {:N 2}))

      ;; sum
      (dl-at-test-set! "sum prices"
        (dl-query
          (dl-program
            "price(apple, 5). price(pear, 7). price(plum, 3).
             total(T) :- sum(T, X, price(F, X)).")
          (list (quote total) (quote T)))
        (list {:T 15}))

      ;; min
      (dl-at-test-set! "min score"
        (dl-query
          (dl-program
            "score(alice, 80). score(bob, 65). score(carol, 92).
             lo(M) :- min(M, S, score(P, S)).")
          (list (quote lo) (quote M)))
        (list {:M 65}))

      ;; max
      (dl-at-test-set! "max score"
        (dl-query
          (dl-program
            "score(alice, 80). score(bob, 65). score(carol, 92).
             hi(M) :- max(M, S, score(P, S)).")
          (list (quote hi) (quote M)))
        (list {:M 92}))

      ;; count over derived relation (stratification needed).
      (dl-at-test-set! "count over derived"
        (dl-query
          (dl-program
            "parent(a, b). parent(a, c). parent(b, d). parent(c, e).
             ancestor(X, Y) :- parent(X, Y).
             ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).
             num_ancestors(N) :- count(N, X, ancestor(a, X)).")
          (list (quote num_ancestors) (quote N)))
        (list {:N 4}))

      ;; count with no matches → 0.
      (dl-at-test-set! "count empty"
        (dl-query
          (dl-program
            "p(1). p(2).
             zero(N) :- count(N, X, q(X)).")
          (list (quote zero) (quote N)))
        (list {:N 0}))

      ;; sum with no matches → 0.
      (dl-at-test-set! "sum empty"
        (dl-query
          (dl-program
            "p(1). p(2).
             total(T) :- sum(T, X, q(X)).")
          (list (quote total) (quote T)))
        (list {:T 0}))

      ;; min with no matches → rule does not fire.
      (dl-at-test-set! "min empty"
        (dl-query
          (dl-program
            "p(1). p(2).
             lo(M) :- min(M, X, q(X)).")
          (list (quote lo) (quote M)))
        (list))

      ;; Aggregate with comparison filter on result.
      (dl-at-test-set! "popularity threshold"
        (dl-query
          (dl-program
            "post(p1). post(p2).
             liked(u1, p1). liked(u2, p1). liked(u3, p1).
             liked(u1, p2). liked(u2, p2).
             popular(P) :- post(P), count(N, U, liked(U, P)), >=(N, 3).")
          (list (quote popular) (quote P)))
        (list {:P (quote p1)}))

      ;; findall: collect distinct values into a list.
      (dl-at-test-set! "findall over EDB"
        (dl-query
          (dl-program
            "p(a). p(b). p(c).
             all_p(L) :- findall(L, X, p(X)).")
          (list (quote all_p) (quote L)))
        (list {:L (list (quote a) (quote b) (quote c))}))

      (dl-at-test-set! "findall over derived"
        (dl-query
          (dl-program
            "parent(a, b). parent(b, c). parent(c, d).
             ancestor(X, Y) :- parent(X, Y).
             ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).
             desc(L) :- findall(L, X, ancestor(a, X)).")
          (list (quote desc) (quote L)))
        (list {:L (list (quote b) (quote c) (quote d))}))

      (dl-at-test-set! "findall empty"
        (dl-query
          (dl-program
            "p(1).
             all_q(L) :- findall(L, X, q(X)).")
          (list (quote all_q) (quote L)))
        (list {:L (list)}))

      ;; Aggregate vs single distinct.
      ;; Group-by via aggregate-in-rule-body. Per-user friend count
      ;; over a friends relation. The U var is bound by the prior
      ;; positive lit u(U) so the aggregate counts only U-rooted
      ;; friends per group.
      (dl-at-test-set! "group-by per-user friend count"
        (dl-query
          (dl-program
            "u(alice). u(bob). u(carol).
             f(alice, x). f(alice, y). f(bob, x).
             counts(U, N) :- u(U), count(N, X, f(U, X)).")
          (list (quote counts) (quote U) (quote N)))
        (list
          {:U (quote alice) :N 2}
          {:U (quote bob) :N 1}
          {:U (quote carol) :N 0}))

      ;; Stratification: recursion through aggregation is rejected.
      ;; Aggregate validates that second arg is a variable.
      (dl-at-test! "agg second arg must be var"
        (dl-at-throws?
          (fn () (dl-eval "p(1). q(N) :- count(N, 5, p(X))." "?- q(N).")))
        true)

      ;; Aggregate validates that third arg is a positive literal.
      (dl-at-test! "agg third arg must be a literal"
        (dl-at-throws?
          (fn () (dl-eval "p(1). q(N) :- count(N, X, 42)." "?- q(N).")))
        true)

      ;; Aggregate validates that the agg-var (2nd arg) appears in the
      ;; goal. Without it every match contributes the same unbound
      ;; symbol — count silently returns 1, sum raises a confusing
      ;; "expected number" error, etc. Catch the mistake at safety
      ;; check time instead.
      (dl-at-test! "agg-var must appear in goal"
        (dl-at-throws?
          (fn ()
            (dl-eval
              "p(1). p(2). c(N) :- count(N, Y, p(X))."
              "?- c(N).")))
        true)

      ;; Indirect recursion through aggregation also rejected.
      ;; q -> r (via positive lit), r -> q (via aggregate body).
      ;; The aggregate edge counts as negation for stratification.
      (dl-at-test! "indirect agg cycle rejected"
        (dl-at-throws?
          (fn ()
            (let ((db (dl-make-db)))
              (do
                (dl-add-rule! db
                  {:head (list (quote q) (quote N))
                   :body (list (list (quote r) (quote N)))})
                (dl-add-rule! db
                  {:head (list (quote r) (quote N))
                   :body (list (list (quote count) (quote N) (quote X)
                                     (list (quote q) (quote X))))})
                (dl-saturate! db)))))
        true)

      (dl-at-test! "agg recursion rejected"
        (dl-at-throws?
          (fn ()
            (let ((db (dl-make-db)))
              (do
                (dl-add-rule! db
                  {:head (list (quote q) (quote N))
                   :body (list (list (quote count) (quote N) (quote X)
                                     (list (quote q) (quote X))))})
                (dl-saturate! db)))))
        true)

      ;; Negation + aggregation in the same body — different strata.
      (dl-at-test-set! "neg + agg coexist"
        (dl-query
          (dl-program
            "u(a). u(b). u(c). banned(b).
             active(X) :- u(X), not(banned(X)).
             cnt(N) :- count(N, X, active(X)).")
          (list (quote cnt) (quote N)))
        (list {:N 2}))

      ;; Min over a derived empty relation: no result.
      (dl-at-test-set! "min over empty derived"
        (dl-query
          (dl-program
            "s(50). s(60).
             score(N) :- s(N), >(N, 100).
             low(M) :- min(M, X, score(X)).")
          (list (quote low) (quote M)))
        (list))

      ;; Aggregates as the top-level query goal (regression for
      ;; dl-match-lit aggregate dispatch and projection cleanup).
      (dl-at-test-set! "count as query goal"
        (dl-query
          (dl-program "p(1). p(2). p(3). p(4).")
          (list (quote count) (quote N) (quote X) (list (quote p) (quote X))))
        (list {:N 4}))

      (dl-at-test-set! "findall as query goal"
        (dl-query
          (dl-program "p(1). p(2). p(3).")
          (list (quote findall) (quote L) (quote X)
                (list (quote p) (quote X))))
        (list {:L (list 1 2 3)}))

      (dl-at-test-set! "distinct counted once"
        (dl-query
          (dl-program
            "rated(alice, x). rated(alice, y). rated(bob, x).
             rater_count(N) :- count(N, U, rated(U, F)).")
          (list (quote rater_count) (quote N)))
        (list {:N 2})))))

(define
  dl-aggregates-tests-run!
  (fn
    ()
    (do
      (set! dl-at-pass 0)
      (set! dl-at-fail 0)
      (set! dl-at-failures (list))
      (dl-at-run-all!)
      {:passed dl-at-pass
       :failed dl-at-fail
       :total (+ dl-at-pass dl-at-fail)
       :failures dl-at-failures})))
