1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
require "pro_tacts/birthday"
require "pro_tacts/contact"
require "pro_tacts/import/match"
require "pro_tacts/import/vcf"
require "pro_tacts/vcard"

module ProTacts
  module Import
    # An arriving card folded into a contact the book already has: what
    # the walk's editor renders when its toggle names that contact
    # rather than a new one (docs/plans/2026-09-23-merging-on-import.md,
    # "The fold"). What it leaves behind is #left, struck on the card
    # as exported (Admin::ImportOriginal).
    #
    # Nothing here writes. The editor renders over #contact, and the
    # save derives it again to check the etag the form carried.
    class Merge
      # @rbs @into: Contact
      # @rbs @labels: Hash[String, Array[VCard::Parser::Line]]
      # @rbs @groups: Hash[String, String]
      # @rbs @named_groups: Array[String]
      # @rbs @arriving_notes: Array[String]
      # @rbs @contact: Contact
      # @rbs @left: Array[VCard::Parser::Line]

      # What a contact holds many of: an arriving one joins the
      # contact's unless the contact already has its value.
      MANY = %w[TEL EMAIL IMPP ADR].freeze #: Array[String]

      # What a contact holds one of: an arriving one fills a gap, and
      # is left behind where the contact has its own.
      ONE = %w[N FN NICKNAME NOTE PHOTO ORG X-ABSHOWAS].freeze #: Array[String]

      # The envelope, which the contact has its own of — its UID above
      # all, the arriving one naming a record in some other book
      # (Import::Write#identified's reason). Neither kept nor a loss.
      ENVELOPE = %w[BEGIN END VERSION UID].freeze #: Array[String]

      # The contact the card is folded into, and the card: the staged
      # one, already pared to what this book reads (Vcf.read).
      #: (Contact into, VCard card) -> void
      def initialize(into, card)
        @into = into
        @labels = labels(card)
        @groups = {} #: Hash[String, String]
        @named_groups = into.stored.properties.filter_map { it.group&.downcase }
        taken = [] #: Array[String]
        arriving_notes = [] #: Array[String]
        left = [] #: Array[VCard::Parser::Line]
        birthday = into.birthday
        card.lines.each do |line|
          property = line.property
          # A line that would not read rides along, Vcf.read's rule:
          # there is nothing about it anyone could have decided.
          if property.nil?
            taken << line.verbatim unless line.verbatim.strip.empty?
            next
          end

          name = property.name.upcase
          if MANY.include?(name)
            taken.concat(moved(line, property)) unless held?(property)
          elsif ONE.include?(name)
            own = into.stored.property(name)
            if own.nil?
              # A contact holds one note, so several arriving join into
              # the one they fill (docs/plans/2026-09-25-one-note-per-
              # contact.md); the joined line is written below the walk.
              if name == "NOTE"
                arriving_notes << property.text
              else
                taken.concat(moved(line, property))
              end
            elsif own.value != property.value
              left << line
            end
          elsif name == "BDAY"
            # Into the model where the contact has no birthday at all,
            # the split Store#put makes on the way in; a spelling the
            # model does not read stays a line, that method's own rule.
            arriving = Birthday.from_property(property)
            if birthday.nil? && into.stored.lines.none? { it.names?("BDAY") }
              if arriving
                birthday = arriving
              else
                taken.concat(moved(line, property))
              end
            elsif arriving != birthday
              left << line
            end
          elsif !ENVELOPE.include?(name) && name != Vcf::LABEL
            left << line
          end
        end
        @left = left
        # The arriving notes' one line, at the walk's end: whatever
        # order they arrived in, the join is the value the book keeps
        # and the one-note invariant asks for.
        unless arriving_notes.empty?
          taken << "NOTE:#{VCard.escape(arriving_notes.join("\n\n"))}\r\n"
        end
        @contact = into.with(stored: into.stored.insert(taken), birthday:)
      end

      # The contact as it would be saved, which the editor renders.
      attr_reader :contact

      # The arriving lines the contact's own stood in for.
      attr_reader :left

      private

      # Whether the contact already has this value, as it serves it:
      # an address a group lends it is an address it has.
      #: (VCard::Parser::Property property) -> bool
      def held?(property)
        if property.name.casecmp?("TEL")
          return @into.properties.any? { it.name.casecmp?("TEL") && same_number?(it.text, property.text) }
        end

        key = key(property)
        @into.properties.any? { it.name.casecmp?(property.name) && key(it) == key }
      end

      # A value as two spellings of it compare: an address by its
      # components, the rest without case or the space around it.
      #: (VCard::Parser::Property property) -> String
      def key(property)
        case property.name.upcase
        when "ADR" then property.components.map { it.strip.downcase }.join(";")
        else property.text.strip.downcase
        end
      end

      # Two spellings of one number, the match's comparison
      # (Match.phone): the same last ten digits, a country code or a
      # trunk prefix on one side and not the other being one number
      # (docs/plans/2026-09-23-merging-on-import.md, "Finding them").
      # What the match can only score, the fold must decide — a number
      # it calls held is one it drops — so two country codes that
      # differ are two numbers, `+44 20 7946 0958` and `+1 207 946
      # 0958` both kept; the leading zeros off a prefix are a trunk,
      # which names no country. A value with no digits at all is no
      # number, and compares as text.
      #: (String ours, String theirs) -> bool
      def same_number?(ours, theirs)
        digits = [ours, theirs].map { it.delete("^0-9") }
        return ours.strip.casecmp?(theirs.strip) if digits.any?(&:empty?)
        return false unless Match.phone(ours) == Match.phone(theirs)

        prefixes = digits.map { it[0...-Match::PHONE_DIGITS].to_s.sub(/\A0+/, "") }
        prefixes.any?(&:empty?) || prefixes.uniq.length == 1
      end

      # A line's bytes as they join the contact's card, with the label
      # hung off it. Apple numbers a card's `item1.` groups per card,
      # so an arriving one can name a group the contact's card already
      # has; it is renumbered past all of them, and its label
      # (Vcf::LABEL) moves with it — Vcf.read's rule that a label goes
      # wherever the line it names goes.
      #: (VCard::Parser::Line line, VCard::Parser::Property property) -> Array[String]
      def moved(line, property)
        group = property.group
        return [line.verbatim] if group.nil?

        fresh = @groups[group.downcase]
        lines = fresh ? [line] : [line, *@labels.fetch(group.downcase, [])]
        fresh ||= (@groups[group.downcase] = fresh_group)
        lines.map { it.verbatim.sub(/\A#{Regexp.escape(group)}\./i, "#{fresh}.") }
      end

      #: () -> String
      def fresh_group
        number = 1
        number += 1 while @named_groups.include?("item#{number}")
        name = "item#{number}"
        @named_groups << name
        name
      end

      # The arriving card's labels, by the group each names.
      #: (VCard card) -> Hash[String, Array[VCard::Parser::Line]]
      def labels(card)
        labels = {} #: Hash[String, Array[VCard::Parser::Line]]
        card.lines.each do |line|
          property = line.property
          next if property.nil? || !property.name.casecmp?(Vcf::LABEL)

          group = property.group
          (labels[group.downcase] ||= []) << line if group
        end
        labels
      end
    end
  end
end