(ns auto-ap.jobs.sales-summaries
  (:require [auto-ap.datomic :refer [conn]]
            [auto-ap.datomic.sales-summaries :as d-ss]
            [auto-ap.jobs.core :refer [execute]]
            [auto-ap.logging :as alog]
            [auto-ap.time :as atime]
            [clj-time.coerce :as c]
            [clj-time.core :as time]
            [clj-time.periodic :as per]
            [clojure.string :as str]
            [com.brunobonacci.mulog :as mu]
            [config.core :refer [env]]
            [datomic.api :as dc]))

(defn mark-dirty [client start end]
  (let [client (dc/entid (dc/db conn) client)]
    @(dc/transact conn
                  (for [s (per/periodic-seq start
                                            end
                                            (time/days 1))]
                    {:sales-summary/client client
                     :sales-summary/date (c/to-date s)
                     :sales-summary/dirty true
                     :sales-summary/client+date [client (c/to-date s)]}))))

(defn last-n-days [n]
  [(.toDateMidnight (atime/localize (time/plus (time/now) (time/days (- n)))))
   (.toDateMidnight (atime/localize (time/now)))])

(defn mark-all-dirty [days]
  (doseq [[c] (dc/q '[:find ?c
                      :in $
                      :where [_ :sales-order/client ?c]]
                    (dc/db conn))]
    (apply mark-dirty c (last-n-days days))))

(defn lookup-account [number]
  (ffirst (dc/q '[:find ?a
                  :in $ ?number
                  :where [?a :account/numeric-code ?number]]
                (dc/db conn)
                number)))

(defn delete-all []
  @(dc/transact-async conn
                      (->>
                       (dc/q '[:find ?ss
                               :where [?ss :sales-summary/date]]
                             (dc/db conn))
                       (map (fn [[ss]]
                              [:db/retractEntity ss])))))

(def item-read
  "Enough of a summary item to both evaluate `d-ss/accepted?` and transact the item back
  unchanged. `:db/id` matters: `:sales-summary/items` is a component attribute upserted via
  `[:reset-rels ...]`, so an item re-transacted without its id is deleted and recreated."
  '[:db/id
    :sales-summary-item/category
    :sales-summary-item/sort-order
    :sales-summary-item/manual?
    :ledger-mapped/amount
    {:ledger-mapped/ledger-side [:db/ident]}
    {:ledger-mapped/account [:db/id]}])

(defn dirty-sales-summaries
  "The client's dirty summaries, with enough of each item to evaluate and re-transact it.

  `index-pull` returns a lazy seq running from `:start` to the END of the index, so this must
  stop at the client boundary rather than filter: `:sales-summary/client+dirty` sorts by client
  first, so every later client's summaries sit beyond this client's and filtering would walk all
  of them — for every client — pulling their items on the way. That is quadratic in the number of
  summaries, and it showed up as a full refresh degrading from ~180 client-days a minute to ~3 as
  the summary count grew."
  [c]
  (let [client-id (dc/entid (dc/db conn) c)]
    (->> (dc/index-pull  (dc/db conn)
                         {:index :avet
                          :selector (conj '[:sales-summary/date :sales-summary/client :db/id]
                                          {:sales-summary/items item-read})
                          :start [:sales-summary/client+dirty [client-id true]]})
         (take-while (fn [sales-summary]
                       (= client-id (:db/id (:sales-summary/client sales-summary))))))))

(def default-days
  "How far back the scheduled refresh looks for summaries that still need recomputing."
  7)

(defn trailing-window
  "`[start end)` covering the last `days` business days, ending with today. `end` is
  exclusive, matching both `periodic-seq`'s 3-arity and the grid's date filters, so
  `(trailing-window 7)` is day -6 through today inclusive."
  [days]
  [(.toDateMidnight (atime/localize (time/minus (time/now) (time/days (dec days)))))
   (.toDateMidnight (atime/localize (time/plus (time/now) (time/days 1))))])

(defn accepted-client+dates
  "Set of `[client-id date]` pairs in `[start end)` whose summary is already accepted, and
  so should be left alone rather than re-marked. Accepted means balanced with every line
  mapped to an account — the condition the grid renders as \"Balanced\"."
  [db start end]
  (->> (dc/q '[:find (pull ?ss selector)
               :in $ ?start ?end selector
               :where
               [?ss :sales-summary/date ?d]
               [(>= ?d ?start)]
               [(< ?d ?end)]]
             db
             (c/to-date start)
             (c/to-date end)
             (conj '[:sales-summary/date {:sales-summary/client [:db/id]}]
                   {:sales-summary/items item-read}))
       (map first)
       (filter #(d-ss/accepted? (map d-ss/<-pulled-item (:sales-summary/items %))))
       (map (juxt (comp :db/id :sales-summary/client) :sales-summary/date))
       set))

(defn mark-stale-dirty
  "Marks every client/day in the trailing `days` window dirty so `sales-summaries-v2` will
  recompute it, skipping days whose summary is already accepted. Because accepted is derived
  rather than stored, a summary that later falls out of balance is picked up again on the
  next run. Returns the number of client/days marked."
  [days]
  (let [db (dc/db conn)
        [start end] (trailing-window days)
        accepted (accepted-client+dates db start end)
        clients (map first (dc/q '[:find ?c
                                   :in $
                                   :where [_ :sales-order/client ?c]]
                                 db))
        dates (map c/to-date (per/periodic-seq start end (time/days 1)))
        tx-data (for [client clients
                      date dates
                      :when (not (accepted [client date]))]
                  {:sales-summary/client client
                   :sales-summary/date date
                   :sales-summary/dirty true
                   :sales-summary/client+date [client date]})]
    (alog/info ::marking-dirty
               :days days
               :client-count (count clients)
               :accepted-count (count accepted)
               :marked (count tx-data))
    (doseq [batch (partition-all 500 tx-data)]
      @(dc/transact conn batch))
    (count tx-data)))

(defn- get-fee [c date]
  (- (or (ffirst (dc/q '[:find ?f
                         :in $ ?client ?d
                         :where
                         [?e :expected-deposit/client ?client]
                         [?e :expected-deposit/sales-date ?d]
                         [?e :expected-deposit/fee ?f]]
                       (dc/db conn)
                       c
                       date))
         0.0)))

(def service-charges-account
  "Where a credited Square service charge lands. 49000 is the existing \"Service Income\"
  revenue account, which is the closest fit for auto-gratuity and catering fees.

  NEEDS ACCOUNTING SIGN-OFF before `service-charges-flag` is enabled for any client: the wrong
  account misstates revenue, and a category with no account at all keeps a day from ever
  reaching accepted, since `accepted?` requires every line to be mapped."
  49000)

(def name->number
  {"gyros and pitas" 40111
   "service charges" service-charges-account
   "returns" 41300
   "card payments" 75460
   "cash payments" 75452
   "cash refunds" 41400
   "food app payments" 72350
   "unknown" 40000
   "discounts" 41000
   "fees" 75400
   "alcohol" 46900
   "beverages" 42000
   "bowls" 40118
   "catering" 43000
   "ezcater catering" 43010
   "desserts" 40116
   "fries" 40117
   "plates" 40113
   "sides" 40115
   "soup & salads" 40114
   "uncategorized" 40000
   "tax" 25700
   "tip" 25500
   "card refunds" 41400
   "food app refunds" 41400})

(defn get-payment-items [c date]
  (->>
   (dc/q '[:find ?processor ?type-name (sum ?total)
           :with ?c
           :in $ [?clients ?start-date ?end-date]
           :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
           [?e :sales-order/charges ?c]
           [?c :charge/type-name ?type-name]
           (or-join [?c ?processor]
                    (and [?c :charge/processor ?p]
                         [?p :db/ident ?processor])
                    (and
                     (not [?c :charge/processor])
                     [(ground :ccp-processor/na) ?processor]))
           [?c :charge/total ?total]]
         (dc/db conn)
         [[c] date date])
   (reduce
    (fn [acc [processor type-name total]]
      (update
       acc
       (cond (= type-name "CARD")
             "Card Payments"
             (= type-name "CASH")
             "Cash Payments"
             (#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name)
             "Gift Card Payments"
             (#{:ccp-processor/toast
                #_:ccp-processor/ezcater
                #_:ccp-processor/koala
                :ccp-processor/doordash
                :ccp-processor/grubhub
                :ccp-processor/uber-eats} processor)
             "Food App Payments"
             :else
             "Unknown")
       (fnil + 0.0)
       total))
    {})
   (map (fn [[k v]]
          {:db/id (str (java.util.UUID/randomUUID))
           :sales-summary-item/sort-order 0
           :sales-summary-item/category k

           :ledger-mapped/amount (if (= "Card Payments" k)
                                   (- v (get-fee c date))
                                   v)
           :ledger-mapped/ledger-side :ledger-side/debit}))))

(defn get-discounts [c date]
  (when-let [discount (ffirst (dc/q '[:find  (sum ?discount)
                                      :with ?e
                                      :in $ [?clients ?start-date ?end-date]
                                      :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                                      [?e :sales-order/discount ?discount]]
                                    (dc/db conn)
                                    [[c] date date]))]
    {:db/id (str (java.util.UUID/randomUUID))
     :sales-summary-item/sort-order 1
     :sales-summary-item/category "Discounts"
     :ledger-mapped/amount discount
     :ledger-mapped/ledger-side :ledger-side/debit}))

(defn get-refund-items [c date]
  (->>
   (dc/q '[:find ?type-name (sum ?t)
           :with ?e
           :in $ [?clients ?start-date ?end-date]
           :where
           :where  [(iol-ion.query/scan-sales-refunds $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
           [?e :sales-refund/type ?type-name]
           [?e :sales-refund/total ?t]]
         (dc/db conn)
         [[c] date date])
   (reduce
    (fn [acc [type-name total]]
      (update
       acc
       (cond (= type-name "CARD")
             "Card Refunds"
             (= type-name "CASH")
             "Cash Refunds"
             :else
             "Food App Refunds")
       (fnil + 0.0)
       total))
    {})
   (map (fn [[k v]]
          {:db/id (str (java.util.UUID/randomUUID))
           :sales-summary-item/sort-order 3
           :sales-summary-item/category k
           :ledger-mapped/amount v
           :ledger-mapped/ledger-side :ledger-side/credit}))))

(defn get-fees [c date]
  (when-let [fee (get-fee c date)]
    {:db/id (str (java.util.UUID/randomUUID))
     :sales-summary-item/sort-order 2
     :sales-summary-item/category "Fees"
     :ledger-mapped/amount fee
     :ledger-mapped/ledger-side :ledger-side/debit}))

(defn- get-tax [c date]
  {:db/id (str (java.util.UUID/randomUUID))
   :sales-summary-item/category "Tax"
   :sales-summary-item/sort-order 1
   :ledger-mapped/ledger-side :ledger-side/credit
   :ledger-mapped/amount
   (or (ffirst (dc/q '[:find  (sum ?tax)
                       :with ?e
                       :in $ [?clients ?start-date ?end-date]
                       :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                       [?e :sales-order/tax ?tax]
                       #_[?e :sales-order/charges ?c]
                       #_[?c :charge/tax ?tax]]
                     (dc/db conn)
                     [[c] date date]))
       0.0)})

(defn- tendered-tip
  "Tips read off the tenders, which is where a tip actually settles."
  [c date]
  (or (ffirst (dc/q '[:find  (sum ?tip)
                      :with ?c
                      :in $ [?clients ?start-date ?end-date]
                      :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                      [?e :sales-order/charges ?c]
                      [?c :charge/tip ?tip]]
                    (dc/db conn)
                    [[c] date date]))
      0.0))

(defn- untendered-tip
  "Tips on orders that carry no tender at all. A return-only order reverses its tip on
  `:sales-order/tip` but has no charge to join through, so the reversal is invisible to
  `tendered-tip` and the day ends up crediting a tip that was handed back."
  [c date]
  (or (ffirst (dc/q '[:find  (sum ?tip)
                      :with ?e
                      :in $ [?clients ?start-date ?end-date]
                      :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                      [?e :sales-order/tip ?tip]
                      (not [?e :sales-order/charges])]
                    (dc/db conn)
                    [[c] date date]))
      0.0))

(defn- get-tip
  "Tendered tips plus the tips on untendered orders. Additive rather than substitutive on
  purpose: where an order does have a tender, the tender is the correct source, and real
  orders exist whose tender carries a tip their `:sales-order/tip` does not — auto-gratuity
  booked as a service charge, and wallet tips absent from the net amounts. Reading the order
  instead of the tender would drop those."
  [c date]
  {:ledger-mapped/ledger-side :ledger-side/credit
   :sales-summary-item/sort-order 2
   :db/id (str (java.util.UUID/randomUUID))

   :sales-summary-item/category "Tip"
   :ledger-mapped/amount (+ (tendered-tip c date)
                            (untendered-tip c date))})

(defn- get-sales [c date]
  (let [sales (->> (dc/q '[:find ?category (sum ?total) (sum ?tax) (sum ?discount)
                           :with ?e ?li
                           :in $ [?clients ?start-date ?end-date]
                           :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                           [?e :sales-order/line-items ?li]
                           [(get-else $ ?li :order-line-item/category "Unknown") ?category]
                           [?li :order-line-item/total ?total]
                           [?li :order-line-item/tax ?tax]
                           [?li :order-line-item/discount ?discount]]
                         (dc/db conn)
                         [[c] date date]))]
    (for [[category total tax discount] sales]
      {:db/id (str (java.util.UUID/randomUUID))
       :sales-summary-item/category category
       :sales-summary-item/sort-order 0
       :sales-summary-item/total total
       :sales-summary-item/net (- (+ total discount) tax)
       :sales-summary-item/tax tax
       :sales-summary-item/discount discount
       :ledger-mapped/ledger-side :ledger-side/credit
       :ledger-mapped/amount (- (+ total discount) tax)
       #_#_:ledger-mapped/account nil})))

(defn- get-returns [c date]
  (when-let [amount (ffirst (dc/q '[:find  (sum ?r)
                                    :with ?e
                                    :in $ [?clients ?start-date ?end-date]
                                    :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                                    [?e :sales-order/returns ?r]

                                    #_[?e :sales-order/charges ?c]
                                    #_[?c :charge/tax ?tax]]
                                  (dc/db conn)
                                  [[c] date date]))]
    {:db/id (str (java.util.UUID/randomUUID))
     :sales-summary-item/category "Returns"

     :ledger-mapped/amount amount
     :ledger-mapped/ledger-side :ledger-side/debit}))

(def service-charges-flag
  "Per-client rollout lever for crediting Square service charges, in the same style as
  `new-square` and `import-custom-amount`. Absent, the summary behaves exactly as it does
  today."
  "summary-service-charges")

(defn- service-charges-enabled? [c]
  (contains? (set (:client/feature-flags (dc/pull (dc/db conn) '[:client/feature-flags] c)))
             service-charges-flag))

(defn service-charge-total
  "Square service charges for the day, both signs.

  A service charge is collected inside the card tender but nothing credits it, so every order
  carrying one leaves the day short by exactly that amount. Both signs matter: a returned
  catering fee arrives as a negative service charge and is subtracted back out of
  `:sales-order/returns`, so dropping negatives would lose the reversal.

  The vendor gate is load-bearing — ezCater service charges are commission deducted from the
  restaurant rather than collected from the diner, and crediting those would make things worse.
  It matches on `:sales-order/vendor` where that is set and falls back to the external id
  prefix where it is not, because whole eras of Square orders carry no vendor attribute at all
  and a gate on vendor alone silently credits nothing.

  Kept separate from the rollout flag so the arithmetic can be measured on its own."
  [c date]
  (ffirst (dc/q '[:find  (sum ?service-charge)
                  :with ?e
                  :in $ [?clients ?start-date ?end-date]
                  :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
                  [?e :sales-order/service-charge ?service-charge]
                  (or-join [?e]
                           [?e :sales-order/vendor :vendor/ccp-square]
                           (and (not [?e :sales-order/vendor])
                                [?e :sales-order/external-id ?external-id]
                                [(clojure.string/starts-with? ?external-id "square/order/")]))]
                (dc/db conn)
                [[c] date date])))

(defn- get-service-charges
  "The day's service charges as a summary item, for clients opted in to the rollout."
  [c date]
  (when (service-charges-enabled? c)
    (when-let [amount (service-charge-total c date)]
      (when-not (zero? amount)
        {:db/id (str (java.util.UUID/randomUUID))
         :sales-summary-item/category "Service Charges"
         :sales-summary-item/sort-order 2
         :ledger-mapped/amount amount
         :ledger-mapped/ledger-side :ledger-side/credit}))))

(def ^:private suspect-categories
  "The terms a balancing investigation keeps returning to. Logged beside the imbalance so a
  day's shape can be read out of the logs without re-running the job."
  ["Tip" "Service Charges" "Returns" "Card Refunds" "Cash Refunds" "Food App Refunds"])

(defn- suspect-totals
  "Amounts for `suspect-categories` present on this day, omitting the ones that are zero."
  [items]
  (into {}
        (for [category suspect-categories
              :let [amount (->> items
                                (filter #(= category (:sales-summary-item/category %)))
                                (map #(:ledger-mapped/amount % 0.0))
                                (reduce + 0.0))]
              :when (not (zero? amount))]
          [category amount])))

(defn refresh-client!
  "Recomputes every dirty summary for one client.

  Split out of the driver loop so a client's work stands on its own: it can be run for a single
  client, and a backfill over the whole history can spread clients across threads instead of
  grinding through the largest ones one day at a time."
  [c client-code]
  (doseq [{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)]
    (mu/with-context {:client-code client-code
                      :date date}
      (alog/info ::updating)
      (let [manual-items (->> existing-summary
                              :sales-summary/items
                              (filter :sales-summary-item/manual?)
                              (map d-ss/<-pulled-item))
            calculated-items (->>
                              (get-sales c date)
                              (concat (get-payment-items c date))
                              (concat (get-refund-items c date))
                              (cons (get-discounts c date))
                              (cons (get-fees c date))
                              (cons (get-tax c date))
                              (cons (get-tip c date))
                              (cons (get-service-charges c date))
                              (cons (get-returns c date))
                              (filter identity)
                              (map (fn [z]
                                     (assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
                                            :sales-summary-item/manual? false))))
            all-items (concat calculated-items manual-items)
            result {:db/id id
                    :sales-summary/client c
                    :sales-summary/date date
                    :sales-summary/dirty false
                    :sales-summary/client+date [c date]
                    :sales-summary/items all-items}]
        (if (seq (:sales-summary/items result))
          (do
            (alog/info ::upserting-summaries
                       :category-count (count (:sales-summary/items result))
                       :imbalance (d-ss/imbalance all-items)
                       :balanced? (d-ss/balanced? all-items)
                       :suspect-totals (suspect-totals all-items))
            @(dc/transact conn [[:upsert-sales-summary result]]))
          @(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))


(defn sales-summaries-v2
  "Recomputes every dirty summary, client by client."
  []
  (doseq [[c client-code] (dc/q '[:find ?c ?client-code
                                  :in $
                                  :where [?c :client/code ?client-code]]
                                (dc/db conn))]
    (refresh-client! c client-code)))

(defn reset-summaries []
  @(dc/transact conn (->> (dc/q '[:find ?sos
                                  :in $
                                  :where [?sos :sales-summary/client]]
                                (dc/db conn))
                          (map (fn [[sos]]
                                 [:db/retractEntity sos])))))

(comment
  (auto-ap.datomic/transact-schema conn)

  (apply mark-dirty [:client/code "NGCL"] (last-n-days 30))

  (apply mark-dirty [:client/code "NGDG"] (last-n-days 30))

  (dirty-sales-summaries [:client/code "NGWH"])

  (apply mark-dirty [:client/code "NGWH"] (last-n-days 5))

  (iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819)

  (iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})

  (mark-all-dirty 5)
  (delete-all)

  (sales-summaries-v2)

  1

  (dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
          :in $
          :where [?sos :sales-summary/client [:client/code "NGHW"]]
          [?sos :sales-summary/date ?d]
          [(= ?d #inst "2024-04-10T00:00:00-07:00")]]
        (dc/db conn))

  (dc/q '[:find  ?n ?p2 (sum ?total)
          :with ?c
          :in $ [?clients ?start-date ?end-date]
          :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
          [?e :sales-order/charges ?c]
          [?c :charge/type-name ?n]
          [?c :charge/processor ?p]
          [?p :db/ident ?p2]
          [?c :charge/total ?total]]
        (dc/db conn)
        [[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"])

  (dc/q '[:find  ?n
          :in $ [?clients ?start-date ?end-date]
          :where  [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
          [?e :sales-order/line-items ?li]
          [?li :order-line-item/item-name ?n]]
        (dc/db conn)
        [[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"])

  @(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
                      {:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])

  (auto-ap.datomic/transact-schema conn))

(defn days-arg
  "Trailing-window size from the job's `args`, e.g. `{:days 30}` set as a container override
  from the admin Background Jobs page for an ad-hoc wider backfill. Values arrive as EDN but
  may still be strings, so coerce defensively the way load-historical-sales does."
  [args]
  (let [days (:days args)]
    (cond-> (or days default-days)
      (string? days) (#(Long/parseLong %)))))

(defn refresh-sales-summaries
  "Marks the trailing `days` window dirty, skipping accepted summaries, then recomputes
  everything left dirty."
  ([] (refresh-sales-summaries default-days))
  ([days]
   (mark-stale-dirty days)
   (sales-summaries-v2)))

(defn -main [& _]
  (execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env)))))
