;; kaikei のブラウザ側。scittle が読む（site/site.edn の :sources の最後）。
;;
;; Re-frame が状態・event・subscription・effect を持ち、Reagent が描く
;; （cloud-itonami ADR-0052）。画面は kaikei.view の純関数で、SSR と同じもの。
;; DOM を直接いじるのは effect（保存・ダウンロード・ファイル読み込み）の中だけ。
;;
;; ネットワークは使わない。fetch も XHR も無い。保存はこの端末の IndexedDB
;; （itonami.kaikei の state ストア）で、使えなければ localStorage に落とす。
;; 1 年分の仕訳帳は数 MB の文字列になり（実測: 5,000 仕訳・1 万行で約 270 万字）、
;; localStorage の上限（ブラウザごとに違い、同じオリジンの mihari・dandori と
;; 分け合う）では足りないことがあるため。読み書きの失敗は画面に出す。

;; ns を宣言する —— scittle は各 script を同じ SCI 文脈で順に評価するので、
;; 宣言しないと直前の script（kaikei.view）の ns のまま評価される。
(ns kaikei.app
  (:require [clojure.edn :as edn]
            [reagent.dom :as rdom]
            [re-frame.core :as rf]
            [kotoba.lang.text :as text]
            [kaikei.core :as core]
            [kaikei.ingest :as ingest]
            [kaikei.export :as export]
            [kaikei.invoice :as invoice]
            [kaikei.statement :as statement]
            [kaikei.compat.statement-csv :as statement-csv]
            [kaikei.view :as view]))

(def storage-key "itonami.kaikei.v1")

;; ───────── effects / coeffects ─────────

(def ^:private idb-name "itonami.kaikei")
(def ^:private idb-store "state")
;; 取り込んだ元のファイルのバイト列（第 4 スライス、判断 5）。帳簿（state）とは別の store
;; に置き、帳簿にはファイルの名前・SHA-256・大きさ・時刻だけを持つ
(def ^:private files-store "files")

(defn- idb-open
  "IndexedDB を開く Promise（使えなければ reject）。版 2 で files の store を足した。"
  []
  (js/Promise.
   (fn [resolve reject]
     (try
       (let [req (.open js/indexedDB idb-name 2)]
         (set! (.-onupgradeneeded req)
               (fn [_]
                 (let [idb (.-result req)]
                   (when-not (.contains (.-objectStoreNames idb) idb-store) (.createObjectStore idb idb-store))
                   (when-not (.contains (.-objectStoreNames idb) files-store) (.createObjectStore idb files-store)))))
         (set! (.-onsuccess req) (fn [_] (resolve (.-result req))))
         (set! (.-onerror req) (fn [_] (reject (.-error req)))))
       (catch :default e (reject e))))))

(defn- idb-request
  ([mode f] (idb-request idb-store mode f))
  ([store mode f]
   (-> (idb-open)
       (.then (fn [idb]
                (js/Promise.
                 (fn [resolve reject]
                   (let [tx (.transaction idb store mode)
                         req (f (.objectStore tx store))]
                     (set! (.-oncomplete tx) (fn [_] (.close idb) (resolve (.-result req))))
                     (set! (.-onerror tx) (fn [_] (.close idb) (reject (.-error tx))))))))))))

(defn- local-get [] (try (.getItem js/localStorage storage-key) (catch :default _ nil)))

(rf/reg-fx
 ::load
 (fn [_]
   ;; IndexedDB に無く localStorage にだけあるもの（前の版の保存）は、そちらを読む。
   ;; 次の保存で IndexedDB へ移り、localStorage の分は消える。
   (-> (idb-request "readonly" #(.get % storage-key))
       (.then (fn [v] (rf/dispatch [::loaded (or v (local-get)) :indexeddb])))
       (.catch (fn [_] (rf/dispatch [::loaded (local-get) :local-storage]))))))

(rf/reg-fx
 ::store
 (fn [{:keys [saved backend]}]
   (let [text (when saved (pr-str saved))
         local! (fn []
                  (try
                    (if text
                      (.setItem js/localStorage storage-key text)
                      (.removeItem js/localStorage storage-key))
                    (rf/dispatch [::stored-ok])
                    (catch :default e (rf/dispatch [::store-failed (.-message e)]))))]
     (if (= backend :local-storage)
       (local!)
       (-> (idb-request "readwrite" #(if text (.put % text storage-key) (.delete % storage-key)))
           (.then (fn [_]
                    (try (.removeItem js/localStorage storage-key) (catch :default _ nil))
                    (rf/dispatch [::stored-ok])))
           (.catch (fn [_] (local!))))))))

(rf/reg-fx
 ::download
 (fn [{:keys [filename mime bytes]}]
   (let [blob (js/Blob. #js [bytes] #js {:type mime})
         url (.createObjectURL js/URL blob)
         a (.createElement js/document "a")]
     (set! (.-href a) url)
     (set! (.-download a) filename)
     (.click a)
     (js/setTimeout #(.revokeObjectURL js/URL url) 1000))))

(rf/reg-fx
 ::read-file
 (fn [file]
   (when file
     ;; バイト列のまま読む —— Shift_JIS の CSV を UTF-8 として読み壊さないため。
     ;; 文字コードの判定は kaikei.encoding が持つ。
     ;; SHA-256 は WebCrypto で（この端末の中で計算し、送らない）
     (-> (.arrayBuffer file)
         (.then (fn [buf]
                  (-> (.digest (.-subtle js/crypto) "SHA-256" buf)
                      (.then (fn [d]
                               (let [hex (apply str (map #(text/pad-left (.toString % 16) 2 "0") (array-seq (js/Uint8Array. d))))]
                                 (rf/dispatch [:run-import-bytes (js/Uint8Array. buf) (.-name file) hex]))))
                      (.catch (fn [_] (rf/dispatch [:run-import-bytes (js/Uint8Array. buf) (.-name file) nil]))))))))))

(rf/reg-fx
 ::store-file
 (fn [{:keys [id bytes]}]
   (-> (idb-request files-store "readwrite" #(.put % bytes id))
       (.catch (fn [e] (rf/dispatch [::store-failed (str "元のファイル: " (.-message e))]))))))

(rf/reg-fx
 ::download-file
 (fn [{:keys [id name]}]
   (-> (idb-request files-store "readonly" #(.get % id))
       (.then (fn [bytes]
                (if bytes
                  (let [blob (js/Blob. #js [bytes] #js {:type "application/octet-stream"})
                        url (.createObjectURL js/URL blob)
                        a (.createElement js/document "a")]
                    (set! (.-href a) url)
                    (set! (.-download a) name)
                    (.click a)
                    (js/setTimeout #(.revokeObjectURL js/URL url) 1000))
                  (rf/dispatch [::store-failed "元のファイルがこの端末にありません（別の端末で取り込んだか、消えています）"]))))
       (.catch (fn [e] (rf/dispatch [::store-failed (.-message e)]))))))

(rf/reg-fx
 ::delete-file
 (fn [id] (-> (idb-request files-store "readwrite" #(.delete % id)) (.catch (fn [_] nil)))))

(rf/reg-fx
 ::clear-files
 (fn [_] (-> (idb-request files-store "readwrite" #(.clear %)) (.catch (fn [_] nil)))))

(rf/reg-fx
 ::print
 (fn [_]
   ;; 印刷用の CSS（kaikei.page/app-css の @media print）が請求書だけを残す。
   ;; 送信はしない（ブラウザの印刷ダイアログで PDF にもできる）。
   (js/setTimeout #(.print js/window) 0)))

(defn- today
  "この端末の今日（\"YYYY-MM-DD\"）。新しい請求書の取引年月日の既定値。"
  []
  (let [d (js/Date.)]
    (str (.getFullYear d) "-" (text/pad-left (str (inc (.getMonth d))) 2 "0") "-"
         (text/pad-left (str (.getDate d)) 2 "0"))))

;; ───────── events ─────────

(defn- saved-state [db]
  {:data (:data db) :settings (select-keys (:ui db) [:fy-start :pl-style])})

(rf/reg-event-fx
 :init
 (fn [_ _]
   {:db {:data core/empty-db :ui (assoc view/initial-ui :booting? true)}
    ::load true}))

(rf/reg-event-db
 ::loaded
 (fn [db [_ text backend]]
   (let [stored (try (some-> text edn/read-string) (catch :default _ nil))
         data (if (map? (:data stored)) (merge core/empty-db (:data stored)) core/empty-db)]
     {:data data
      :backend backend
      :ui (merge (:ui db) {:booting? false} (select-keys (:settings stored) [:fy-start :pl-style]))})))

(defn- with-store
  "db → db' の effect に、変えた data を保存する ::store を足す（data が空なら保存を消す）。"
  [db db' fx]
  (cond-> (assoc fx :db db')
    (or (not= (:data db) (:data db'))
        (not= (saved-state db) (saved-state db')))
    (assoc ::store {:backend (:backend db')
                    :saved (when (or (seq (:entries (:data db'))) (seq (:accounts (:data db')))
                                     (seq (:invoices (:data db'))) (seq (:business (:data db')))
                                     (seq (:statement-lines (:data db'))) (seq (:rules (:data db'))))
                             (saved-state db'))})))

(defn- reg-data-event
  "data を変える event。handler は (fn [db event] db')。変えた data を ::store
  effect で保存する（handler 自身は純関数のまま）。"
  [id f]
  (rf/reg-event-fx
   id
   (fn [{:keys [db]} event]
     (with-store db (f db event) {}))))

(defn- reg-ui-event [id f]
  (rf/reg-event-db id (fn [db event] (f db event))))

(rf/reg-event-db ::stored-ok (fn [db _] (assoc-in db [:ui :store-error] nil)))
(rf/reg-event-db ::store-failed (fn [db [_ msg]] (assoc-in db [:ui :store-error] msg)))

(reg-ui-event :set-mode (fn [db [_ m]] (assoc-in db [:ui :mode] m)))
(reg-ui-event :toggle-import (fn [db _] (update-in db [:ui :import-open?] not)))
(reg-ui-event :set-import (fn [db [_ field v]] (assoc-in db [:ui field] v)))
(reg-ui-event :dismiss-import (fn [db _] (assoc-in db [:ui :import-result] nil)))
(reg-ui-event :dismiss-export (fn [db _] (assoc-in db [:ui :export-result] nil)))
(reg-ui-event :set-export-format (fn [db [_ v]] (update db :ui assoc :export-format v :export-encoding nil)))
(reg-ui-event :set-export-encoding (fn [db [_ v]] (assoc-in db [:ui :export-encoding] v)))
(reg-ui-event :ask-clear (fn [db _] (assoc-in db [:ui :confirm-clear?] true)))
(reg-ui-event :cancel-clear (fn [db _] (assoc-in db [:ui :confirm-clear?] false)))
(reg-ui-event :set-problems-only (fn [db [_ v]] (update db :ui assoc :problems-only? (boolean v) :journal-page 0)))
(reg-ui-event :set-journal-page (fn [db [_ n]] (assoc-in db [:ui :journal-page] (max 0 n))))
(reg-ui-event :show-problems (fn [db _] (update db :ui assoc :mode :journal :problems-only? true :journal-page 0)))
(reg-ui-event :show-account (fn [db [_ a]] (update db :ui assoc :mode :accounts :account a)))

(reg-ui-event :set-period-preset
              (fn [db [_ v]]
                (let [[from to] (text/split (str v) #"\|")]
                  (if (and (core/parse-date from) (core/parse-date to))
                    (update db :ui assoc :period {:from from :to to} :journal-page 0)
                    db))))

(reg-ui-event :set-period-date
              (fn [db [_ k v]]
                (let [current (view/period (:data db) (:ui db))]
                  (if (and current (core/parse-date v))
                    (update db :ui assoc :period (assoc current k (core/parse-date v)) :journal-page 0)
                    db))))

(reg-data-event :set-fy-start (fn [db [_ v]] (update db :ui assoc :fy-start (or (parse-long (str v)) 1) :period nil)))
(reg-data-event :set-pl-style (fn [db [_ v]] (assoc-in db [:ui :pl-style] v)))

(rf/reg-event-fx :import-file (fn [_ [_ file]] {::read-file file}))

(defn- import-into [db result & [file]]
  (let [mode (if (= "replace" (get-in db [:ui :import-mode])) :replace :append)
        {data :db :keys [added duplicates error] :as merged}
        (core/merge-import (:data db) result mode (cond-> {:statement-account (get-in db [:ui :import-account] "auto")}
                                                    file (assoc :file file)))]
    (-> db
        (assoc :data data)
        (update :ui assoc
                :import-result (merge (select-keys result [:format :encoding :rejected :invoices-rejected])
                                      {:added added :duplicates duplicates :error error
                                       :warnings (vec (concat (:warnings result) (:warnings merged)))}
                                      (select-keys merged [:invoices-added :invoices-duplicates :invoices-dropped
                                                           :lines-added :lines-duplicates :lines-corrected :lines-dropped]))
                :import-open? (zero? (+ (or added 0) (or (:invoices-added merged) 0) (or (:lines-added merged) 0)))
                :period nil
                :journal-page 0)
        (cond-> (:file-id merged) (assoc ::stored-file (:file-id merged))))))

(defn- now-iso
  "この端末の今の時刻（ISO 8601、時差つき）。取り込んだ元のファイルの記録に使う。"
  []
  (let [d (js/Date.)
        off (- (.getTimezoneOffset d))
        p2 #(text/pad-left (str %) 2 "0")]
    (str (.getFullYear d) "-" (p2 (inc (.getMonth d))) "-" (p2 (.getDate d)) "T" (p2 (.getHours d)) ":" (p2 (.getMinutes d))
         ":" (p2 (.getSeconds d)) (if (neg? off) "-" "+") (p2 (quot (js/Math.abs off) 60)) ":" (p2 (mod (js/Math.abs off) 60)))))

(defn- import-opts [ui]
  {:format (:import-format ui) :currency (:import-currency ui) :encoding (:import-encoding ui)})

(defn- account-mapping
  "明細を入れる口座に残した列の割り当て（あれば）。"
  [db]
  (some->> (get-in db [:ui :import-account]) (statement/account-by-id (:data db)) :mapping))

(defn- unrecognized-csv?
  "形式を判定できなかった CSV か、割り当ての無い汎用の明細 CSV か。"
  [result]
  (or (and (nil? (:format result)) (empty? (:entries result)))
      (and (= :statement-csv (:format result)) (empty? (:statement-lines result)) (empty? (:statement-accounts result)))))

(defn- file-meta [bytes filename sha256 result]
  (when (and sha256 (seq (:statement-lines result)))
    {:name filename :sha256 sha256 :size (.-length bytes) :imported-at (now-iso)
     :format (some-> (:format result) name)}))

(defn- store-file-fx
  "取り込みで行を足したときだけ、元のバイト列を IndexedDB に置く effect。"
  [db bytes]
  (let [id (::stored-file db)
        db (dissoc db ::stored-file)]
    (cond-> {:db db} id (assoc ::store-file {:id id :bytes bytes}))))

(rf/reg-event-fx
 :run-import-bytes
 (fn [{:keys [db]} [_ bytes filename sha256]]
   (let [opts (import-opts (:ui db))
         result (ingest/ingest-bytes bytes opts)
         mapping (account-mapping db)
         result (if (and (unrecognized-csv? result) mapping)
                  (ingest/ingest-bytes bytes (assoc opts :format "statement-csv" :mapping mapping))
                  result)]
     (if (unrecognized-csv? result)
       ;; 形式を判定できなかった CSV: 列の割り当てを出す（判断 3）
       (let [{:keys [rows encoding]} (ingest/decode-csv bytes (:encoding opts))]
         {:db (update db :ui assoc
                      :import-pending {:bytes bytes :name filename :sha256 sha256 :encoding encoding :rows (vec (take 30 rows))}
                      :import-mapping (view/mapping->ui (or mapping {}))
                      :import-result nil)})
       (let [fx (store-file-fx (import-into db result (file-meta bytes filename sha256 result)) bytes)]
         (with-store db (:db fx) fx))))))

(reg-ui-event :set-import-mapping (fn [db [_ k v]] (assoc-in db [:ui :import-mapping k] v)))
(reg-ui-event :cancel-import-mapping (fn [db _] (update db :ui assoc :import-pending nil :import-mapping nil)))
(reg-ui-event :apply-preset
              (fn [db [_ id]]
                (if-let [p (statement-csv/preset-by-id id)]
                  (assoc-in db [:ui :import-mapping] (view/mapping->ui (assoc (:mapping p) :preset id)))
                  (assoc-in db [:ui :import-mapping :preset] ""))))

(rf/reg-event-fx
 :run-import-mapped
 (fn [{:keys [db]} _]
   (let [{:keys [bytes name sha256 encoding]} (get-in db [:ui :import-pending])
         mapping (view/ui->mapping (get-in db [:ui :import-mapping]))
         result (ingest/ingest-bytes bytes (assoc (import-opts (:ui db)) :format "statement-csv" :mapping mapping :encoding encoding))
         db' (-> (import-into db result (file-meta bytes name sha256 result))
                 (update :ui assoc :import-pending nil :import-mapping nil))
         fx (store-file-fx db' bytes)]
     (with-store db (:db fx) fx))))

(rf/reg-event-fx
 :download-original
 (fn [{:keys [db]} [_ id]]
   (when-let [f (first (filter #(= id (:id %)) (get-in db [:data :files])))]
     {::download-file {:id id :name (:name f)}})))

(rf/reg-event-fx
 :delete-original
 (fn [{:keys [db]} [_ id]]
   ;; 記録とバイト列の両方を消す（明細の行は帳簿に残る）
   (with-store db
     (-> db
         (update-in [:data :files] (fn [fs] (vec (remove #(= id (:id %)) fs))))
         (assoc-in [:ui :confirm-delete-file] nil))
     {::delete-file id})))

(reg-data-event
 :run-import-text
 (fn [db _]
   (let [db' (import-into db (ingest/ingest-text (get-in db [:ui :import-text]) (import-opts (:ui db))))]
     (cond-> db'
       (pos? (or (get-in db' [:ui :import-result :added]) 0)) (assoc-in [:ui :import-text] "")))))

(reg-data-event
 :load-sample
 (fn [db _]
   (-> db
       (assoc :data (:db (core/merge-import (:data db) core/sample-import)))
       (update :ui assoc :fy-start 4 :period nil))))

(reg-data-event
 :set-class
 (fn [db [_ account v]]
   (update db :data core/set-class account (keyword v))))

(rf/reg-event-fx
 :clear-all
 (fn [{:keys [db]} _]
   (with-store db (assoc db :data core/empty-db :ui view/initial-ui) {::clear-files true})))

(rf/reg-event-fx
 :export
 (fn [{:keys [db]} _]
   (let [ui (:ui db)
         result (export/export (:data db) (:export-format ui)
                               (cond-> {} (:export-encoding ui) (assoc :encoding (:export-encoding ui))))]
     (cond-> {:db (assoc-in db [:ui :export-result] (select-keys result [:error :notes :filename]))}
       (:bytes result) (assoc ::download (select-keys result [:filename :mime :bytes]))))))

;; ───────── 請求書（第 3 スライス、JUN-321） ─────────

(defn- exp-of [db] (core/exponent (get-in db [:data :currency])))

(reg-ui-event :set-invoice-kind (fn [db [_ k]] (update db :ui assoc :invoice-kind k :invoice nil)))
(reg-ui-event :set-invoice-filter (fn [db [_ v]] (assoc-in db [:ui :invoice-filter] v)))
(reg-ui-event :show-invoices (fn [db [_ f]] (update db :ui assoc :mode :invoices :invoice-filter (or f "all"))))
(reg-ui-event :select-invoice (fn [db [_ id]] (update db :ui assoc :invoice id :invoice-draft nil :confirm-delete-invoice nil)))
(reg-ui-event :close-invoice (fn [db _] (update db :ui assoc :invoice nil)))
(reg-ui-event :toggle-invoice-settings (fn [db _] (update-in db [:ui :invoice-settings-open?] not)))
(reg-ui-event :dismiss-invoice-result (fn [db _] (assoc-in db [:ui :invoice-result] nil)))
(reg-ui-event :ask-delete-invoice (fn [db [_ id]] (assoc-in db [:ui :confirm-delete-invoice] id)))
(reg-ui-event :cancel-delete-invoice (fn [db _] (assoc-in db [:ui :confirm-delete-invoice] nil)))

(reg-ui-event :new-invoice
              (fn [db [_ kind]]
                (update db :ui assoc :mode :invoices :invoice-kind kind :invoice nil :invoice-errors nil
                        :invoice-draft (invoice/new-draft (:data db) kind (today)))))

(reg-ui-event :edit-invoice
              (fn [db [_ id]]
                (if-let [inv (invoice/invoice-by-id (:data db) id)]
                  (update db :ui assoc :invoice-draft (invoice/invoice->draft inv (exp-of db)) :invoice-errors nil)
                  db)))

(reg-ui-event :cancel-draft (fn [db _] (update db :ui assoc :invoice-draft nil :invoice-errors nil)))

(def ^:private keyword-fields #{:kind :status :tax-mode :rounding})

(reg-ui-event :set-draft
              (fn [db [_ field v]]
                (assoc-in db [:ui :invoice-draft field] (if (keyword-fields field) (keyword v) v))))

(reg-ui-event :set-draft-line (fn [db [_ i field v]] (assoc-in db [:ui :invoice-draft :lines i field] v)))
(reg-ui-event :add-draft-line (fn [db _] (update-in db [:ui :invoice-draft :lines] conj invoice/empty-draft-line)))
(reg-ui-event :remove-draft-line
              (fn [db [_ i]]
                (update-in db [:ui :invoice-draft :lines]
                           (fn [ls] (if (> (count ls) 1) (vec (concat (subvec ls 0 i) (subvec ls (inc i)))) ls)))))

(reg-data-event
 :save-draft
 (fn [db _]
   (let [draft (get-in db [:ui :invoice-draft])
         base (some->> (:id draft) (invoice/invoice-by-id (:data db)))
         {:keys [invoice errors]} (invoice/draft->invoice draft (exp-of db) base)]
     (if errors
       (assoc-in db [:ui :invoice-errors] errors)
       (let [{data :db :keys [id]} (invoice/save-invoice (:data db) invoice)]
         (-> db
             (assoc :data data)
             (update :ui assoc :invoice-draft nil :invoice-errors nil :invoice id
                     :invoice-kind (:kind invoice) :period nil)))))))

(defn- after-posting [db data]
  (-> db (assoc :data data) (assoc-in [:ui :period] nil)))

(reg-data-event :post-invoice (fn [db [_ id]] (after-posting db (:db (invoice/post (:data db) id)))))
(reg-data-event :repost-invoice (fn [db [_ id]] (after-posting db (:db (invoice/repost (:data db) id)))))
(reg-data-event :unpost-invoice (fn [db [_ id]] (after-posting db (invoice/unpost (:data db) id))))

(reg-data-event
 :post-all-invoices
 (fn [db _]
   (let [{data :db :keys [posted skipped]} (invoice/post-all (:data db))]
     (-> (after-posting db data)
         (assoc-in [:ui :invoice-result] {:posted posted :skipped skipped})))))

(reg-data-event
 :delete-invoice
 (fn [db [_ id]]
   (-> (after-posting db (invoice/delete-invoice (:data db) id))
       (update :ui assoc :invoice nil :confirm-delete-invoice nil))))

(reg-data-event
 :set-business
 (fn [db [_ field v]]
   (let [business (assoc (get-in db [:data :business]) field v)]
     (assoc-in db [:data :business] (into {} (remove (comp text/blank? str val)) business)))))

(reg-data-event :set-invoice-setting
                (fn [db [_ k v]] (assoc-in db [:data :invoice-settings k] (keyword v))))
(reg-data-event :set-invoice-account
                (fn [db [_ k v]] (assoc-in db [:data :invoice-settings :accounts k] v)))

(rf/reg-event-fx :print-invoice (fn [_ _] {::print true}))

;; ───────── 明細（第 4 スライス、JUN-327） ─────────

(reg-ui-event :set-ui (fn [db [_ k v]] (assoc-in db [:ui k] v)))
(reg-ui-event :toggle-ui (fn [db [_ k]] (update-in db [:ui k] not)))
(reg-ui-event :show-statements (fn [db [_ f]] (update db :ui assoc :mode :statements :statement-filter (or f "open") :statement-page 0)))
(reg-ui-event :select-statement-account
              (fn [db [_ id]] (update db :ui assoc :statement-account id :statement-line nil :statement-selected #{} :statement-page 0)))
(reg-ui-event :set-statement-filter
              (fn [db [_ v]] (update db :ui assoc :statement-filter v :statement-page 0 :statement-selected #{})))
(reg-ui-event :close-statement-line (fn [db _] (update db :ui assoc :statement-line nil :statement-line-result nil)))
(reg-ui-event :dismiss-statement-result (fn [db _] (assoc-in db [:ui :statement-result] nil)))
(reg-ui-event :toggle-statement-select
              (fn [db [_ id checked]] (update-in db [:ui :statement-selected] (fnil (if checked conj disj) #{}) id)))
(reg-ui-event :set-statement-form (fn [db [_ k v]] (assoc-in db [:ui :statement-form k] v)))
(reg-ui-event :set-rule-draft (fn [db [_ k v]] (update db :ui #(-> % (assoc-in [:rule-draft k] v) (dissoc :rule-conflicts)))))

(reg-ui-event
 :select-statement-line
 (fn [db [_ id]]
   ;; 科目の欄は規則の候補で埋めておく（承認は利用者が押すまでしない）
   (let [data (:data db)
         line (statement/line-by-id data id)
         s (when line (statement/rule-suggestion data line))]
     (update db :ui assoc :statement-line id :statement-line-result nil :statement-fee-rate ""
             :statement-form {:account (if (= 3 (:level s)) (or (:account s) "") "")
                              ;; 消費税（日本の税率）は JPY の帳簿だけの既定。ほかの通貨の帳簿は不課税
                              :tax (cond (:tax s) (view/tax->choice (:tax s))
                                         (= "JPY" (:currency data)) "taxable-0"
                                         :else "out-of-scope")
                              :partner (or (:partner s) "") :qualified? (boolean (:qualified? s)) :make-rule? false}))))

(reg-ui-event
 :select-strong-statements
 (fn [db _]
   (let [sd (view/derive-statements (:data db) (:ui db))]
     (assoc-in db [:ui :statement-selected]
               (set (for [{:keys [line candidates]} (:rows sd) :when (:preselected candidates)] (:id line)))))))

(defn- after-statement [db {data :db :as r}]
  (-> db
      (assoc :data data)
      (update :ui assoc :statement-line-result (dissoc r :db) :period nil)))

(reg-data-event
 :post-statement-candidate
 (fn [db [_ line-id i]]
   (let [data (:data db)
         line (statement/line-by-id data line-id)
         {:keys [candidates preselected]} (statement/candidates data line)
         c (nth candidates i nil)
         fee-rate (parse-long (str (get-in db [:ui :statement-fee-rate])))]
     (if c
       (after-statement db (statement/post-line data line-id (cond-> (assoc c :as-suggested? (= c preselected))
                                                                fee-rate (assoc :fee-rate fee-rate))))
       db))))

(reg-data-event
 :post-statement-form
 (fn [db [_ line-id]]
   (let [form (get-in db [:ui :statement-form])
         data (:data db)
         line (statement/line-by-id data line-id)
         s (statement/rule-suggestion data line)
         choice (cond-> {:type :account :account (:account form) :tax (view/tax-choice->tax (:tax form))
                         :partner (:partner form) :qualified? (:qualified? form) :make-rule? (:make-rule? form)}
                  (= :user (:source s)) (assoc :rule-id (:rule-id s)
                                               :as-suggested? (= (:account s) (text/trim (str (:account form))))))]
     (after-statement db (statement/post-line data line-id choice)))))

(reg-data-event :exclude-statement-line
                (fn [db [_ id]] (after-statement db (statement/post-line (:data db) id {:type :exclude}))))
(reg-data-event :unpost-statement-line
                (fn [db [_ id]] (-> db (assoc :data (statement/unpost-line (:data db) id))
                                    (update :ui assoc :statement-line-result nil :period nil))))

(reg-data-event
 :post-selected-statements
 (fn [db _]
   (let [ids (vec (get-in db [:ui :statement-selected]))
         {data :db :keys [posted skipped]} (statement/post-preselected (:data db) ids)]
     (-> db
         (assoc :data data)
         (update :ui assoc :statement-selected #{} :statement-result {:posted posted :skipped skipped} :period nil)))))

(reg-data-event
 :add-rule
 (fn [db _]
   (let [{:keys [text op direction account tax]} (get-in db [:ui :rule-draft])
         rule {:when (cond-> {:text (text/trim (str text)) :op (keyword op)}
                       (not (text/blank? (str direction))) (assoc :direction (keyword direction)))
               :then {:account (text/trim (str account)) :tax (view/tax-choice->tax tax)}}
         conflicts (statement/rule-conflicts (:data db) rule)]
     (if (and (seq conflicts) (not= conflicts (get-in db [:ui :rule-conflicts])))
       ;; 別の科目に記帳した摘要にも当たるときは、1 度だけ知らせる（もう 1 度押すと足す）
       (assoc-in db [:ui :rule-conflicts] conflicts)
       (-> db
           (update :data statement/add-rule rule)
           (update :ui assoc :rule-conflicts nil :rule-draft (:rule-draft view/initial-ui)))))))

(reg-data-event :delete-rule (fn [db [_ id]] (update db :data statement/delete-rule id)))
(reg-data-event :move-rule (fn [db [_ id delta]] (update db :data statement/move-rule id delta)))

(reg-data-event
 :set-statement-setting
 (fn [db [_ k v]]
   (case k
     :fee-treatment (assoc-in db [:data :statement-settings :fee-treatment] (keyword v))
     :max-fee (if-let [n (parse-long (text/trim (str v)))]
                (assoc-in db [:data :statement-settings :max-fee] (max 0 n))
                db)
     :fee-account (assoc-in db [:data :statement-settings :accounts :fee] v)
     :discount-account (assoc-in db [:data :statement-settings :accounts :discount] v)
     db)))

(reg-data-event
 :delete-alias
 (fn [db [_ partner k]]
   (update-in db [:data :statement-settings :aliases partner] (fn [ks] (vec (remove #{k} ks))))))

(reg-data-event
 :set-statement-account-field
 (fn [db [_ id field v]]
   (update db :data statement/set-account-field id field (if (= field :kind) (keyword v) v))))

(rf/reg-event-fx
 :export-statements
 (fn [{:keys [db]} [_ account-id]]
   (let [ui (:ui db)
         result (export/export (:data db) (:statement-export-format ui) {:statement-account account-id})]
     (cond-> {:db (assoc-in db [:ui :export-result] (select-keys result [:error :notes :filename]))}
       (:bytes result) (assoc ::download (select-keys result [:filename :mime :bytes]))))))

;; ───────── subscription / 描画 ─────────

(rf/reg-sub ::data (fn [db _] (:data db)))
(rf/reg-sub ::ui (fn [db _] (:ui db)))

;; 期間は値（{:from :to}）なので、ui の他の欄が変わっても帳票は計算し直さない
(rf/reg-sub ::period :<- [::data] :<- [::ui] (fn [[data ui] _] (view/period data ui)))
(rf/reg-sub ::derived :<- [::data] :<- [::period] (fn [[data p] _] (view/derive data p)))

;; 明細のタブの値は、帳簿か明細のタブの表示（口座・絞り込み・ページ）が変わったときだけ
(rf/reg-sub ::statement-view-ui (fn [db _] (select-keys (:ui db) [:statement-account :statement-filter :statement-page :mode])))
(rf/reg-sub ::statements :<- [::data] :<- [::statement-view-ui]
            (fn [[data sui] _] (when (= :statements (:mode sui)) (view/derive-statements data sui))))

(defn- handlers
  "kaikei.view の h。ev は re-frame の event ベクタ。"
  [kind ev]
  (case kind
    :click (fn [_] (rf/dispatch ev))
    :value (fn [e] (rf/dispatch (conj ev (.. e -target -value))))
    :checked (fn [e] (rf/dispatch (conj ev (.. e -target -checked))))
    :file (fn [e] (rf/dispatch (conj ev (aget (.. e -target -files) 0))))
    nil))

(defn root []
  (let [data @(rf/subscribe [::data])
        ui @(rf/subscribe [::ui])
        derived @(rf/subscribe [::derived])
        statements @(rf/subscribe [::statements])]
    [:<>
     (when-let [e (:store-error ui)]
       [:p {:class "kk-warnings kk-app" :role "alert"}
        (str "この端末に保存できませんでした（" e "）。書き出して手元に残してください。")])
     ;; :booting? の間は SSR と同じ「読み込んでいます」を描く（保存の読み出しは非同期）
     (view/root (cond-> {:data data :ui ui} statements (assoc :statements statements)) derived handlers)]))

(rf/dispatch-sync [:init])
(rdom/render [root] (.getElementById js/document "app"))
