(ns auto-ap.jobs.rekey-square-external-ids
  "One-shot migration re-keying Square entities to client-scoped external ids.

  Refunds, charges, Square payouts (expected deposits) and cash drawer shifts all carry keys with
  no client scoping, so two clients configured on the same Square location share a single entity:
  its owner flips every time either client imports. Sales orders and ezCater orders already scope
  their keys by client and location; this brings the rest in line.

  Measured on a restored production backup, ownership had actually changed on 3,387 refunds,
  4,069 expected deposits and 2,628 cash drawer shifts, across 19 distinct client pairs — nine of
  which no longer share a location in the current configuration and so are invisible to any
  point-in-time check.

  Run AFTER the importer knows how to resolve both key schemes (`square.core3/existing-id`).
  Running it first would be harmless, but the importer would then re-create legacy-keyed
  entities on its next pass.

  The migration is idempotent: an entity already carrying its scoped key is skipped, so it can
  be re-run over a partially migrated database."
  (:require
   [auto-ap.datomic :refer [conn]]
   [auto-ap.logging :as alog]
   [datomic.api :as dc]))

(def refund-prefix "square/refund/")
(def charge-prefix "square/charge/")
(def deposit-prefix "square/payout/")
(def shift-prefix "square/cash-drawer-shift/")

(defn- scope-of
  "`[client-code location]` for an entity, or nil when it cannot be determined.

  Charges are the awkward case: about an eighth of them carry neither `:charge/client` nor
  `:charge/location`. Those are stubs minted by the payout path, which asserts an external id
  alone and lets unique-identity upsert bring a bare entity into being, plus older tender
  records that predate the client attribute. None are orphaned, so the scope is recovered from
  whatever references them — the sales order first, then the expected deposit."
  [db attr e]
  (let [ent (dc/entity db e)
        pair (fn [code loc] (when (and code loc) [code loc]))]
    (or (case attr
          :sales-refund/external-id (pair (:client/code (:sales-refund/client ent))
                                          (:sales-refund/location ent))
          :charge/external-id (pair (:client/code (:charge/client ent))
                                    (:charge/location ent))
          :expected-deposit/external-id (pair (:client/code (:expected-deposit/client ent))
                                              (:expected-deposit/location ent))
          :cash-drawer-shift/external-id (pair (:client/code (:cash-drawer-shift/client ent))
                                               (:cash-drawer-shift/location ent)))
        (when-let [o (:e (first (dc/datoms db :vaet e :sales-order/charges)))]
          (let [oe (dc/entity db o)]
            (pair (:client/code (:sales-order/client oe)) (:sales-order/location oe))))
        (when-let [d (:e (first (dc/datoms db :vaet e :expected-deposit/charges)))]
          (let [de (dc/entity db d)]
            (pair (:client/code (:expected-deposit/client de)) (:expected-deposit/location de)))))))

(defn planned-key
  "`[eid new-key]` for an entity that still needs re-keying, or nil when it is already scoped or
  cannot be scoped at all.

  Detection compares against the key this entity *should* have rather than pattern-matching the
  id, because Square ids may themselves contain dashes and no pattern separates the two schemes
  reliably. That also makes the migration idempotent."
  [db attr prefix datom]
  (let [old (:v datom)]
    (when-let [[code loc] (scope-of db attr (:e datom))]
      (let [scoped-prefix (str prefix code "-" loc "-")]
        (when-not (.startsWith ^String old scoped-prefix)
          [(:e datom) (str scoped-prefix (subs old (count prefix)))])))))

(defn plan
  "Everything the migration would change, plus what it cannot touch. Read-only — run this and
  check `:collisions` is empty before transacting anything."
  [db attr prefix]
  (let [acc (reduce (fn [acc d]
                      (let [acc (update acc :total inc)]
                        (if-let [[e new-key] (planned-key db attr prefix d)]
                          (-> acc
                              (update :to-migrate inc)
                              (update :new-keys conj! [e new-key]))
                          (if (scope-of db attr (:e d))
                            (update acc :already-scoped inc)
                            (update acc :unscopable inc)))))
                    {:total 0 :to-migrate 0 :already-scoped 0 :unscopable 0 :new-keys (transient [])}
                    (dc/datoms db :aevt attr))]
    (update acc :new-keys persistent!)))

(defn collisions
  "Any two entities that would land on the same new key. Must be empty: a collision would merge
  two entities into one and lose whichever lost."
  [new-keys]
  (->> new-keys
       (group-by second)
       (keep (fn [[k es]] (when (> (count es) 1) [k (mapv first es)])))
       vec))

(defn migrate!
  "Asserts the new external id on each planned entity. The attribute is cardinality one, so the
  legacy value is retracted by the same assertion and the entity keeps its identity — nothing is
  created and nothing is deleted.

  Returns the number of entities re-keyed."
  [attr new-keys batch-size]
  (let [total (count new-keys)]
    (alog/info ::migrating :attr attr :count total)
    (doseq [[i batch] (map-indexed vector (partition-all batch-size new-keys))]
      @(dc/transact conn (for [[e new-key] batch] {:db/id e attr new-key}))
      (when (zero? (mod i 20))
        (alog/info ::migrated :attr attr :done (* i batch-size) :of total)))
    total))

(def charge-copy-attrs
  "Everything a charge carries in its own right. `:charge/client+date` is a tuple Datomic
  maintains, and `:charge/external-id` is set separately, so neither is copied."
  [:charge/type-name :charge/total :charge/tip :charge/tax :charge/date
   :charge/processor :charge/note :charge/reference-link])

(defn- raw-square-id
  "The Square id inside a charge's external id, with any client scoping removed.

  Stripping only the `square/charge/` prefix is not enough. Once a charge has been scoped to one
  client, a second order processing the same charge would read `NGCC-CC-<id>` as the id and scope
  it again, producing `square/charge/NGCD-CD-NGCC-CC-<id>`. The importer then computes the
  correct single-scoped key, fails to find it, and creates a second charge — silently doubling
  the tender.

  Client codes may themselves contain dashes, so the scope cannot be recognised by pattern. It is
  recovered from the entity instead: whoever the charge currently belongs to is exactly whose
  scope its key carries."
  [db charge old]
  (let [ent (dc/entity db charge)
        code (:client/code (:charge/client ent))
        loc (:charge/location ent)
        owner-prefix (when (and code loc) (str charge-prefix code "-" loc "-"))]
    (cond
      (and owner-prefix (.startsWith ^String old ^String owner-prefix)) (subs old (count owner-prefix))
      (.startsWith ^String old charge-prefix) (subs old (count charge-prefix))
      :else old)))

(defn- charge-plan-for-order
  "What one order needs doing to its charges.

  `:keep` — no other order has claimed this charge yet, so this order takes it and it is renamed
  in place. `:clone` — another order already owns it, so this order needs its own copy.

  Splitting is per client, not per order. Where two orders of the SAME client and location refer
  to one payment — Square splitting a tender across orders, or an amendment — they are left
  sharing it deliberately. Both would compute the same name, so there is no second name to give
  a copy, and more importantly a copy would double that client's takings for the day. The
  component cascade still applies to those, which is why the retraction guard on
  `remove-voided-orders` is needed regardless of this migration.

  Whether a charge is claimed is read from the charge itself: an unclaimed one still carries the
  bare `square/charge/<id>` form. Deciding it that way rather than by remembering
  every charge seen so far is what lets this run across all 16 million of them — the alternative
  needs a map of the entire table in memory. `batch-seen` covers only the orders inside one
  transaction, where the database snapshot cannot yet show a claim made moments earlier."
  [db order-eid batch-seen]
  (let [oe (dc/entity db order-eid)
        code (:client/code (:sales-order/client oe))
        loc (:sales-order/location oe)]
    (when (and code loc)
      (for [d (dc/datoms db :eavt order-eid :sales-order/charges)
            :let [charge (:v d)
                  old (:v (first (dc/datoms db :eavt charge :charge/external-id)))]
            :when old
            :let [raw (raw-square-id db charge old)
                  new-key (str charge-prefix code "-" loc "-" raw)
                  claimed (get @batch-seen charge)
                  unclaimed? (and (= old (str charge-prefix raw)) (nil? claimed))]
            ;; nothing to do when the charge already answers to this order's name, or when
            ;; another order in this same batch has just claimed it under that very name —
            ;; that is the same-client case, which stays shared
            :when (and (not= old new-key) (not= claimed new-key))]
        {:order order-eid :charge charge :new-key new-key :raw raw
         :client (:db/id (:sales-order/client oe)) :location loc
         :action (if unclaimed? :keep :clone)}))))

(defn split-and-rekey-charges!
  "Gives every order its own charge entity, keyed by that order's client and location.

  Where two clients were configured on one Square location, both clients' orders resolved to a
  single charge, because charge keys carried no client. Re-keying alone does not undo that — it
  hands the one entity to whichever client is looked at first and leaves the other order pointing
  at a charge it does not own. Since `:sales-order/charges` is a component attribute, that is not
  merely untidy: retracting either order would delete a charge the other one still needs.

  So a shared charge is cloned. The first order to claim it keeps it, re-keyed to that order's
  scope; every other order gets a copy carrying the same amounts, scoped to its own client, and
  has its reference repointed. Afterwards no charge has more than one parent order and the
  component relationship means what it says.

  `orders` is the collection of order entity ids to process — typically every order belonging to
  the clients that share, or have ever shared, a Square location."
  [orders batch-size]
  (let [cloned (atom 0)
        rekeyed (atom 0)]
    (doseq [batch (partition-all batch-size orders)]
      (let [db (dc/db conn)
            batch-seen (atom {})
            tx (doall
                (for [o batch
                      plan (charge-plan-for-order db o batch-seen)
                      :let [{:keys [charge new-key action client location]} plan]
                      tx-item (if (= :keep action)
                                (do (swap! batch-seen assoc charge new-key)
                                    (swap! rekeyed inc)
                                    ;; record who claimed it: the owner is how a later order
                                    ;; recovers the Square id from an already-scoped key
                                    [{:db/id charge
                                      :charge/external-id new-key
                                      :charge/client client
                                      :charge/location location}])
                                (let [ent (dc/entity db charge)
                                      copy (reduce (fn [m a] (if-some [v (get ent a)]
                                                               (assoc m a (if (map? v) (:db/id v) v))
                                                               m))
                                                   {} charge-copy-attrs)]
                                  (swap! cloned inc)
                                  [(assoc copy
                                          :db/id new-key
                                          :charge/external-id new-key
                                          :charge/client client
                                          :charge/location location)
                                   [:db/retract o :sales-order/charges charge]
                                   {:db/id o :sales-order/charges new-key}]))]
                  tx-item))]
        (when (seq tx) @(dc/transact conn tx))))
    (alog/info ::split-charges :rekeyed @rekeyed :cloned @cloned)
    {:rekeyed @rekeyed :cloned @cloned}))

(defn charges-with-multiple-parents
  "The §3.3 gate. Must read zero once the split has run: while any charge has two parent orders,
  retracting either order deletes the other one's payment."
  [db orders]
  (->> orders
       (mapcat (fn [o] (map :v (dc/datoms db :eavt o :sales-order/charges))))
       distinct
       (filter (fn [c] (> (reduce (fn [n _] (inc n)) 0 (dc/datoms db :vaet c :sales-order/charges)) 1)))
       count))

(def scoped-attrs
  "Every Square-imported entity whose key must carry its client, with the prefix and where to
  read the owner from."
  [{:attr :sales-refund/external-id  :prefix refund-prefix
    :client :sales-refund/client     :location :sales-refund/location}
   {:attr :charge/external-id        :prefix charge-prefix
    :client :charge/client           :location :charge/location}
   {:attr :expected-deposit/external-id :prefix deposit-prefix
    :client :expected-deposit/client    :location :expected-deposit/location}
   {:attr :cash-drawer-shift/external-id :prefix shift-prefix
    :client :cash-drawer-shift/client    :location :cash-drawer-shift/location}])

(defn unscoped-report
  "Counts, per entity type, how many keys are already client-scoped, how many still carry the
  legacy unscoped form, and how many have no owner to scope by.

  This is the completeness gate. The importer tolerates both key schemes on purpose, so that the
  change can be deployed before the migration finishes — but that tolerance is a transition, not
  a resting place. While `:legacy` is above zero the database is in a mixed state and a stray
  unscoped record can still be adopted by whichever client imports it first. Once every count
  reads zero the fallback lookup in `square.core3/existing-id` can be removed and the guarantee
  becomes structural rather than conventional."
  [db]
  (into {}
        (for [{:keys [attr prefix client location]} scoped-attrs]
          [attr (reduce (fn [acc d]
                          (let [e (dc/entity db (:e d))
                                code (:client/code (client e))
                                loc (location e)
                                scoped (when (and code loc) (str prefix code "-" loc "-"))]
                            (cond
                              (and scoped (.startsWith ^String (:v d) ^String scoped)) (update acc :scoped inc)
                              (nil? scoped) (update acc :no-owner inc)
                              :else (update acc :legacy inc))))
                        {:scoped 0 :legacy 0 :no-owner 0}
                        (dc/datoms db :aevt attr))])))

(defn all-order-ids
  "Every sales order in the database, streamed."
  [db]
  (map :e (dc/datoms db :aevt :sales-order/external-id)))

(defn migrate-all!
  "The complete migration, over the whole database rather than a chosen subset.

  Splitting is driven from orders, because a payment's rightful owner is whichever order refers
  to it — so every order has to be walked, not merely the clients that share a location today.
  Nine client pairs contended in the past and no longer share one; their records are still mixed,
  and a migration scoped to the current configuration would miss every one of them.

  Returns the split counts and the completeness report, which should read zero legacy across the
  board when this finishes."
  [batch-size]
  (let [split (split-and-rekey-charges! (all-order-ids (dc/db conn)) batch-size)]
    (doseq [{:keys [attr prefix]} scoped-attrs
            :when (not= attr :charge/external-id)]
      (let [p (plan (dc/db conn) attr prefix)]
        (when-let [c (seq (collisions (:new-keys p)))]
          (throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
        (migrate! attr (:new-keys p) batch-size)))
    ;; charges no order refers to — payout stubs — are scoped from the deposit that holds them
    (let [p (plan (dc/db conn) :charge/external-id charge-prefix)]
      (when (seq (:new-keys p)) (migrate! :charge/external-id (:new-keys p) batch-size)))
    {:split split :completeness (unscoped-report (dc/db conn))}))

(defn counts
  "Entity totals, for the before/after assertion that is this migration's real safety net: if
  either number moves, the re-key created duplicates instead of updating in place."
  [db]
  {:refunds (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :sales-refund/external-id))
   :charges (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :charge/external-id))
   :deposits (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :expected-deposit/external-id))
   :shifts (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :cash-drawer-shift/external-id))})
