;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL

(ns common-tests.files-migrations-0025-test
  (:require
   [app.common.files.migrations :as cfm]
   [app.common.types.shape :as cts]
   [app.common.uuid :as uuid]
   [clojure.test :as t]))

;; 0025-repair-empty-text-content
;; Text shapes whose :content is a root with an empty/missing :children
;; vector used to slip past the schema (children was optional). With the
;; schema tightening those shapes must be repaired on next load.
(defn- make-text-shape-with-content
  "Build a text shape with arbitrary content structure"
  [shape-id content]
  (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
      (assoc :content content)))

(defn- make-broken-text-shape
  "Build a fully-initialised text shape with a broken :content and the
  supplied root-level attrs overlaid on it."
  [shape-id root-attrs]
  (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
      (assoc :content (merge {:type "root"}
                             (when (seq root-attrs) root-attrs)
                             {:children []}))))

(t/deftest migration-0025-repair-empty-text-content-empty-children
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-broken-text-shape shape-id {:vertical-align "top"})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "root type preserved")
    (t/is (vector? (:children content)) "children is now a vector")
    (t/is (= 1 (count (:children content))) "exactly one paragraph-set seeded")
    (t/is (= "paragraph-set" (get-in content [:children 0 :type])))
    (t/is (pos? (count (get-in content [:children 0 :children])))
          "paragraph-set has at least one paragraph")
    (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text]))
          "seeded span has empty text")
    (t/is (= "top" (:vertical-align content))
          "preserves pre-existing :vertical-align")))

(t/deftest migration-0025-repair-empty-text-content-missing-children
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        ;; A text shape whose :content has no :children key at all.
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "center"}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (:children content)) "missing children becomes a vector")
    (t/is (pos? (count (:children content))) "missing children gets a paragraph-set")
    (t/is (= "center" (:vertical-align content))
          "preserves pre-existing :vertical-align")))

(t/deftest migration-0025-repair-empty-text-content-no-content
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        ;; A text shape with no :content at all. Should be repaired with default content.
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (map? content) "content is now a map")
    (t/is (= "root" (:type content)) "content has root type")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-repair-empty-text-content-idempotent
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        ;; A healthy text shape with a proper paragraph-set/paragraph/
        ;; span tree. The migration must leave it untouched.
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "top"
                                                   :children [{:type "paragraph-set"
                                                               :children [{:type "paragraph"
                                                                           :children [{:text "hello"}]}]}]}))}}}}
        original (get-in data [:pages-index page-id :objects shape-id])
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape'   (get-in data' [:pages-index page-id :objects shape-id])]

    (t/is (cts/valid-shape? original) "baseline shape is valid")
    (t/is (= original shape') "healthy content is unchanged")))

(t/deftest migration-0025-repair-empty-text-content-component
  ;; The migration also walks :components, so a broken text inside a
  ;; component is also repaired.
  (let [shape-id (uuid/next)
        comp-id  (uuid/next)
        data     {:components
                  {comp-id
                   {:objects
                    {shape-id (make-broken-text-shape shape-id nil)}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:components comp-id :objects shape-id])]

    (t/is (cts/valid-shape? shape) "repaired component shape is valid")
    (t/is (pos? (count (get-in shape [:content :children])))
          "children vector is no longer empty")))

(t/deftest migration-0025-repair-empty-text-content-level2
  ;; Level 2: paragraph-set with empty/missing children
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "top"
                                                   :children [{:type "paragraph-set"
                                                               :children []}]}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "paragraph-set preserved")
    (t/is (pos? (count (get-in content [:children 0 :children])))
          "paragraph-set now has at least one paragraph")
    (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type]))
          "seeded child is a paragraph")
    (t/is (= "top" (:vertical-align content))
          "preserves pre-existing :vertical-align")))

(t/deftest migration-0025-repair-empty-text-content-level3
  ;; Level 3: paragraph with empty/missing children
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "top"
                                                   :children [{:type "paragraph-set"
                                                               :children [{:type "paragraph"
                                                                           :children []}]}]}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "paragraph preserved")
    (t/is (pos? (count (get-in content [:children 0 :children 0 :children])))
          "paragraph now has at least one span")
    (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text]))
          "seeded span has empty text")
    (t/is (= "top" (:vertical-align content))
          "preserves pre-existing :vertical-align")))

(t/deftest migration-0025-repair-empty-text-content-mixed-levels
  ;; Valid level 1, but broken at levels 2 and 3 in different paragraph-sets
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "top"
                                                   :children [{:type "paragraph-set"
                                                               :children []}
                                                              {:type "paragraph-set"
                                                               :children [{:type "paragraph"
                                                                           :children []}]}]}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= 2 (count (:children content))) "both paragraph-sets preserved")
    ;; First paragraph-set had empty children (level 2 broken)
    (t/is (pos? (count (get-in content [:children 0 :children])))
          "first paragraph-set now has paragraphs")
    ;; Second paragraph-set had a paragraph with empty children (level 3 broken)
    (t/is (pos? (count (get-in content [:children 1 :children 0 :children])))
          "second paragraph's paragraph now has spans")))

;; ============================================================================
;; Category A: Shape-level guards (fix-shape)
;; ============================================================================

(t/deftest migration-0025-non-text-shape-untouched
  ;; A: Non-text shape should not be processed
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0})}}}}
        original (get-in data [:pages-index page-id :objects shape-id])
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape'   (get-in data' [:pages-index page-id :objects shape-id])]

    (t/is (= original shape') "non-text shape is unchanged")))

(t/deftest migration-0025-text-shape-non-map-content-repaired
  ;; A: Text shape with non-map content should be repaired
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content "not a map"))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (map? content) "content is now a map")
    (t/is (= "root" (:type content)) "content has root type")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-text-shape-wrong-root-type-repaired
  ;; A: Text shape with content :type not "root" should be repaired, preserving root-level attrs
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "paragraph"
                                                   :vertical-align "center"
                                                   :children []}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "type is now root")
    (t/is (= "center" (:vertical-align content)) "root-level attrs preserved")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-text-shape-nil-content
  ;; I: Text shape with :content nil should be repaired with default content
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content nil))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (map? content) "content is now a map")
    (t/is (= "root" (:type content)) "content has root type")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-text-shape-empty-map-content
  ;; I: Text shape with :content {} (empty map) should be repaired with default content
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "type is now root")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-text-shape-wrong-type-with-root-attrs
  ;; I: Text shape with wrong type but valid root-level attrs should preserve attrs
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "paragraph"
                                                   :vertical-align "bottom"
                                                   :children []}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "type is now root")
    (t/is (= "bottom" (:vertical-align content)) "root-level attrs preserved")
    (t/is (vector? (:children content)) "children is a vector")
    (t/is (pos? (count (:children content))) "has at least one paragraph-set")))

(t/deftest migration-0025-text-shape-partial-salvage-paragraphs-under-root
  ;; K: Root has children but they're paragraphs (not paragraph-sets) - should preserve level 1 attrs
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
                                  (assoc :content {:type "root"
                                                   :vertical-align "top"
                                                   :children [{:type "paragraph"
                                                               :children [{:text "hello"}]}]}))}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "root type preserved")
    (t/is (= "top" (:vertical-align content)) "root-level attrs preserved")
    (t/is (= 1 (count (:children content))) "has one paragraph-set")
    (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "child is paragraph-set")
    (t/is (pos? (count (get-in content [:children 0 :children]))) "paragraph-set has paragraphs")))

;; ============================================================================
;; Category B: Level 1 (root) variants
;; ============================================================================

(t/deftest migration-0025-root-non-vector-children-map
  ;; B: Root with non-vector children (map) - GAP: should repair
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :vertical-align "top"
                                :children {:invalid "map"}})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "root" (:type content)) "root type preserved")
    (t/is (vector? (:children content)) "children is now a vector")
    (t/is (= "top" (:vertical-align content)) "preserves vertical-align")))

(t/deftest migration-0025-root-non-vector-children-string
  ;; B: Root with non-vector children (string) - GAP: should repair
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :vertical-align "center"
                                :children "not a vector"})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (:children content)) "children is now a vector")
    (t/is (= "center" (:vertical-align content)) "preserves vertical-align")))

;; ============================================================================
;; Category C: Level 2 (paragraph-set) variants
;; ============================================================================

(t/deftest migration-0025-paragraph-set-nil-children
  ;; C: Paragraph-set with nil children key
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children])) "children is a vector")
    (t/is (pos? (count (get-in content [:children 0 :children]))) "has at least one paragraph")))

(t/deftest migration-0025-paragraph-set-non-vector-children-map
  ;; C: Paragraph-set with non-vector children (map)
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children {:invalid "map"}}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector")
    (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "seeded with default paragraph")))

(t/deftest migration-0025-paragraph-set-non-vector-children-string
  ;; C: Paragraph-set with non-vector children (string)
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children "not a vector"}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector")))

(t/deftest migration-0025-paragraph-set-item-not-map
  ;; C: Paragraph-set with non-map item in children vector
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children ["not a map"]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "non-map item replaced with default paragraph")))

(t/deftest migration-0025-paragraph-set-mixed-valid-nil-non-map
  ;; C: Paragraph-set with mix of valid paragraphs, nil, and non-map items
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children [{:text "ok"}]}
                                                       nil
                                                       "not-a-map"
                                                       {:type "paragraph"
                                                        :children [{:text "also ok"}]}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        paragraphs (get-in shape [:content :children 0 :children])]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= 4 (count paragraphs)) "all items preserved as paragraphs")
    (t/is (= "paragraph" (:type (nth paragraphs 0))) "valid paragraph preserved")
    (t/is (= "ok" (:text (get-in (nth paragraphs 0) [:children 0]))) "valid span text preserved")
    (t/is (= "paragraph" (:type (nth paragraphs 1))) "nil replaced with default paragraph")
    (t/is (= "paragraph" (:type (nth paragraphs 2))) "non-map replaced with default paragraph")
    (t/is (= "paragraph" (:type (nth paragraphs 3))) "valid paragraph preserved")
    (t/is (= "also ok" (:text (get-in (nth paragraphs 3) [:children 0]))) "valid span text preserved")))

(t/deftest migration-0025-paragraph-set-wrong-type
  ;; C: Paragraph-set with wrong :type
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph"
                                            :children []}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "wrong type replaced with default paragraph-set")))

;; ============================================================================
;; Category D: Level 3 (paragraph) variants
;; ============================================================================

(t/deftest migration-0025-paragraph-nil-children
  ;; D: Paragraph with nil children key
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is a vector")
    (t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) "has at least one span")))

(t/deftest migration-0025-paragraph-non-vector-children-map
  ;; D: Paragraph with non-vector children (map) - GAP: should repair
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children {:invalid "map"}}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector")
    (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "seeded with default span")))

(t/deftest migration-0025-paragraph-non-vector-children-string
  ;; D: Paragraph with non-vector children (string) - GAP: should repair
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children "not a vector"}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector")))

(t/deftest migration-0025-paragraph-item-not-map
  ;; D: Paragraph with non-map item in children vector
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children ["not a map"]}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "non-map item replaced with default span")))

(t/deftest migration-0025-paragraph-wrong-type
  ;; D: Paragraph with wrong :type
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "span"
                                                        :text "hello"}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "wrong type replaced with default paragraph")))

(t/deftest migration-0025-paragraph-valid-spans-preserved
  ;; D: Paragraph with valid spans should be preserved
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children [{:text "hello"}
                                                                   {:text "world"}]}]}]})}}}}
        original-spans (get-in data [:pages-index page-id :objects shape-id :content :children 0 :children 0 :children])
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= 2 (count (get-in content [:children 0 :children 0 :children]))) "both spans preserved")
    (t/is (= original-spans (get-in content [:children 0 :children 0 :children])) "spans unchanged")))

;; ============================================================================
;; Category E: Preservation tests
;; ============================================================================

(t/deftest migration-0025-root-attrs-preserved-level2-repair
  ;; E: Root-level attrs preserved when level 2 repaired
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :vertical-align "bottom"
                                :children [{:type "paragraph-set"
                                            :children []}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "bottom" (:vertical-align content)) "root attrs preserved during level 2 repair")))

(t/deftest migration-0025-paragraph-set-attrs-preserved
  ;; E: Paragraph-set attrs preserved when repaired at level 2
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :custom-attr "preserve-me"
                                            :children []}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "preserve-me" (get-in content [:children 0 :custom-attr])) "paragraph-set attrs preserved")))

(t/deftest migration-0025-paragraph-attrs-preserved
  ;; E: Paragraph attrs preserved when repaired at level 3
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :text-align "center"
                                                        :children []}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= "center" (get-in content [:children 0 :children 0 :text-align])) "paragraph attrs preserved")))

;; ============================================================================
;; Category F: Multi-item tests
;; ============================================================================

(t/deftest migration-0025-multiple-paragraphs-mixed-valid-broken
  ;; F: Multiple paragraphs within one paragraph-set, mix of valid and broken
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children [{:text "valid"}]}
                                                       {:type "paragraph"
                                                        :children []}
                                                       {:type "paragraph"
                                                        :children [{:text "also valid"}]}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= 3 (count (get-in content [:children 0 :children]))) "all three paragraphs preserved")
    (t/is (= "valid" (get-in content [:children 0 :children 0 :children 0 :text])) "first paragraph preserved")
    (t/is (= "" (get-in content [:children 0 :children 1 :children 0 :text])) "second paragraph repaired")
    (t/is (= "also valid" (get-in content [:children 0 :children 2 :children 0 :text])) "third paragraph preserved")))

(t/deftest migration-0025-multiple-spans-all-preserved
  ;; F: Multiple spans within one paragraph (all should be preserved)
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root"
                                :children [{:type "paragraph-set"
                                            :children [{:type "paragraph"
                                                        :children [{:text "span1"}
                                                                   {:text "span2"}
                                                                   {:text "span3"}]}]}]})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        shape    (get-in data' [:pages-index page-id :objects shape-id])
        content  (:content shape)]

    (t/is (cts/valid-shape? shape) "repaired shape is valid")
    (t/is (= 3 (count (get-in content [:children 0 :children 0 :children]))) "all spans preserved")
    (t/is (= "span1" (get-in content [:children 0 :children 0 :children 0 :text])))
    (t/is (= "span2" (get-in content [:children 0 :children 0 :children 1 :text])))
    (t/is (= "span3" (get-in content [:children 0 :children 0 :children 2 :text])))))

;; ============================================================================
;; Category G: Container coverage
;; ============================================================================

(t/deftest migration-0025-multiple-pages-broken-shapes
  ;; G: Multiple pages, each with broken shapes
  (let [shape-id-1 (uuid/next)
        shape-id-2 (uuid/next)
        page-id-1  (uuid/next)
        page-id-2  (uuid/next)
        data       {:pages-index
                    {page-id-1
                     {:objects
                      {shape-id-1 (make-text-shape-with-content
                                   shape-id-1
                                   {:type "root" :children []})}}
                     page-id-2
                     {:objects
                      {shape-id-2 (make-text-shape-with-content
                                   shape-id-2
                                   {:type "root" :children []})}}}}
        data'      (cfm/migrate-data data "0025-repair-empty-text-content")
        shape-1    (get-in data' [:pages-index page-id-1 :objects shape-id-1])
        shape-2    (get-in data' [:pages-index page-id-2 :objects shape-id-2])]

    (t/is (cts/valid-shape? shape-1) "first page shape is valid")
    (t/is (cts/valid-shape? shape-2) "second page shape is valid")
    (t/is (pos? (count (get-in shape-1 [:content :children]))) "first page shape repaired")
    (t/is (pos? (count (get-in shape-2 [:content :children]))) "second page shape repaired")))

(t/deftest migration-0025-container-without-objects
  ;; G: Container without :objects key should not crash
  (let [page-id (uuid/next)
        data    {:pages-index
                 {page-id {}}}
        data'   (cfm/migrate-data data "0025-repair-empty-text-content")]

    (t/is (= data data') "container without objects is unchanged")))

;; ============================================================================
;; Category H: Idempotency
;; ============================================================================

(t/deftest migration-0025-already-repaired-unchanged
  ;; H: Already-repaired content unchanged (run migration twice)
  (let [shape-id (uuid/next)
        page-id  (uuid/next)
        data     {:pages-index
                  {page-id
                   {:objects
                    {shape-id (make-text-shape-with-content
                               shape-id
                               {:type "root" :children []})}}}}
        data'    (cfm/migrate-data data "0025-repair-empty-text-content")
        data''   (cfm/migrate-data data' "0025-repair-empty-text-content")
        shape'   (get-in data' [:pages-index page-id :objects shape-id])
        shape''  (get-in data'' [:pages-index page-id :objects shape-id])]

    (t/is (cts/valid-shape? shape') "first repair produces valid shape")
    (t/is (cts/valid-shape? shape'') "second repair produces valid shape")
    (t/is (= shape' shape'') "migration is idempotent")))
