ContactRepository.swift 340 lignes · 12581 octets
import Foundation

struct VcfImportResult: Equatable {
    var imported: Int
    var skippedDuplicates: Int
    var errors: Int
}

/// Image-file abstraction for the repository (Android used `ContactImageStore`
/// with `Bitmap`s; the iOS implementation lives with the Scan module owner).
/// Bitmap-saving paths were replaced by pre-saved file paths in the API below.
protocol ContactImageStoring {
    /// Copies (re-encoding if needed) an existing image into the target contact's
    /// storage. Returns the new path, or nil when the source is missing/unreadable.
    func copyImage(fromPath: String?, toContactId: Int64, name: String) -> String?

    /// Deletes every stored image of the contact.
    func deleteAll(contactId: Int64)
}

/// Sync payload mapping (Android `ContactSyncMapper`; the iOS implementation
/// lives in the Sync module and should conform to this protocol).
protocol ContactSyncPayloadMapping {
    func toCreatePayload(_ entity: CrmContactEntity) -> String
    func toUpdatePayload(_ entity: CrmContactEntity) -> String
}

final class ContactRepository {
    private let dao: CrmContactDao
    private let images: ContactImageStoring?
    private let syncOpDao: SyncOpDao?
    private let syncMapper: ContactSyncPayloadMapping?
    private let isSyncEnabled: () -> Bool

    init(
        dao: CrmContactDao,
        images: ContactImageStoring? = nil,
        syncOpDao: SyncOpDao? = nil,
        syncMapper: ContactSyncPayloadMapping? = nil,
        isSyncEnabled: @escaping () -> Bool = { false }
    ) {
        self.dao = dao
        self.images = images
        self.syncOpDao = syncOpDao
        self.syncMapper = syncMapper
        self.isSyncEnabled = isSyncEnabled
    }

    static func currentMillis() -> Int64 {
        Int64(Date().timeIntervalSince1970 * 1000)
    }

    /// Kotlin `observeAll(): Flow` — manual refresh model on iOS.
    func fetchAll() async throws -> [CrmContactEntity] {
        try await dao.fetchAll()
    }

    func getById(_ id: Int64) async throws -> CrmContactEntity? {
        try await dao.getById(id)
    }

    func getExclusions() async throws -> [DuplicateExclusionEntity] {
        try await dao.getExclusions()
    }

    func search(_ query: String) async throws -> [CrmContactEntity] {
        let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
        if q.isEmpty { return try await dao.getAll() }
        // Alphanumeric tokens only — avoids FTS MATCH crashes on `"` `*` `:` etc.
        let tokens = q.components(separatedBy: .whitespacesAndNewlines)
            .map { token in String(token.filter { $0.isLetter || $0.isNumber }) }
            .filter { !$0.isEmpty }
        if tokens.isEmpty { return [] }
        let token = tokens.map { "\($0)*" }.joined(separator: " ")
        let ids = try await dao.searchIds(token)
        var out: [CrmContactEntity] = []
        for id in ids {
            if let entity = try await dao.getById(id) { out.append(entity) }
        }
        return out
    }

    /// Android saved `Bitmap`s through `ContactImageStore`; on iOS the caller
    /// saves the files first and passes the resulting paths.
    /// `notesOverride` mirrors Kotlin's `notesOverride: String? = card.note`:
    /// omit it to use `card.note`, pass `.some(nil)` to explicitly clear notes.
    @discardableResult
    func insertFromCard(
        _ card: ContactCard,
        cardImagePath: String? = nil,
        profileImagePath: String? = nil,
        notesOverride: String?? = nil,
        now: Int64 = ContactRepository.currentMillis()
    ) async throws -> Int64 {
        let notes = notesOverride ?? card.note
        let entity = card.toEntity(notes: notes, now: now)
        let id = try await dao.insert(entity)
        var inserted = entity
        inserted.id = id
        var updated = inserted
        if let cardImagePath {
            updated.cardImagePath = cardImagePath
        }
        if let profileImagePath {
            updated.profileImagePath = profileImagePath
        }
        if updated != inserted {
            try await dao.update(updated)
        }
        if let stored = try await dao.getById(id) {
            try await enqueueContactCreate(stored)
        }
        return id
    }

    func updateFromCard(
        id: Int64,
        card: ContactCard,
        cardImagePath: String? = nil,
        profileImagePath: String? = nil,
        preserveNotesIfBlankDraft: Bool = true,
        now: Int64 = ContactRepository.currentMillis()
    ) async throws {
        guard let existing = try await dao.getById(id) else { return }
        let notes: String?
        if preserveNotesIfBlankDraft && (card.note?.isBlank ?? true) {
            notes = existing.notes
        } else {
            notes = card.note
        }
        var updated = card.toEntity(notes: notes, now: now)
        updated.id = id
        updated.createdAt = existing.createdAt
        updated.cardImagePath = existing.cardImagePath
        updated.profileImagePath = existing.profileImagePath
        updated.serverId = existing.serverId
        updated.statut = existing.statut
        updated.etape = existing.etape
        updated.tags = existing.tags
        // When the company name changes, invalidate the link: the server re-resolves by name.
        let newCompany = card.company?.trimmingCharacters(in: .whitespacesAndNewlines)
        let oldCompany = existing.company?.trimmingCharacters(in: .whitespacesAndNewlines)
        updated.entrepriseServerId = newCompany == oldCompany ? existing.entrepriseServerId : nil
        if let cardImagePath {
            updated.cardImagePath = cardImagePath
        }
        if let profileImagePath {
            updated.profileImagePath = profileImagePath
        }
        try await dao.update(updated)
        if let stored = try await dao.getById(id) {
            try await enqueueContactUpdate(stored)
        }
    }

    func delete(_ id: Int64) async throws {
        let existing = try await dao.getById(id)
        try await dao.deleteExclusionsFor(id)
        try await dao.deleteById(id)
        images?.deleteAll(contactId: id)
        if let existing {
            try await enqueueContactDelete(existing)
        }
    }

    // Kotlin `rotateCardImage(id:degrees:)` is not ported: it re-encoded the
    // card JPEG with Android Bitmap/Matrix. On iOS the image rotation belongs
    // to the image-store/Scan module owner.

    func markDifferent(_ a: Int64, _ b: Int64) async throws {
        let lo = min(a, b)
        let hi = max(a, b)
        try await dao.insertExclusion(DuplicateExclusionEntity(idA: lo, idB: hi))
    }

    func mergeContacts(
        sources: [CrmContactEntity],
        choices: MergeFieldChoices
    ) async throws {
        let map = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
        var merged = ContactMerge.merge(sources: sources, choices: choices)
        let keepId = choices.keepId
        if let srcId = choices.cardImageFromId {
            let path = map[srcId]?.cardImagePath
            if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "card.jpg") {
                merged.cardImagePath = copied
            }
        }
        if let srcId = choices.profileImageFromId {
            let path = map[srcId]?.profileImagePath
            if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "profile.jpg") {
                merged.profileImagePath = copied
            }
        }
        try await dao.update(merged)
        for s in sources where s.id != keepId {
            try await delete(s.id)
        }
    }

    func findMatchingId(card: ContactCard, among: [CrmContactEntity]) -> Int64? {
        let emails = Set(card.emails.map(ContactNormalizer.email).filter { !$0.isEmpty })
        let phones = Set(card.phones.map(ContactNormalizer.phone).filter { !$0.isEmpty })
        for e in among {
            let eEmails = Set(e.emails.map(ContactNormalizer.email))
            let ePhones = Set(e.phones.map(ContactNormalizer.phone))
            if !emails.isDisjoint(with: eEmails) || !phones.isDisjoint(with: ePhones) {
                return e.id
            }
        }
        return nil
    }

    func importCards(_ cards: [ContactCard]) async throws -> VcfImportResult {
        var imported = 0
        var skipped = 0
        var errors = 0
        var existing = try await dao.getAll()
        for card in cards {
            do {
                if !card.hasAnyField() {
                    errors += 1
                    continue
                }
                if findMatchingId(card: card, among: existing) != nil {
                    skipped += 1
                    continue
                }
                let id = try await insertFromCard(card)
                if let stored = try await dao.getById(id) {
                    existing.append(stored)
                }
                imported += 1
            } catch {
                errors += 1
            }
        }
        return VcfImportResult(imported: imported, skippedDuplicates: skipped, errors: errors)
    }

    private func enqueueContactCreate(_ contact: CrmContactEntity) async throws {
        guard isSyncEnabled(), let syncOpDao, let syncMapper else { return }
        try await syncOpDao.insert(
            SyncOpEntity(
                entityType: "contact",
                op: "create",
                payloadJson: syncMapper.toCreatePayload(contact),
                localId: contact.id,
                createdAt: ContactRepository.currentMillis()
            )
        )
    }

    /// If not yet synchronized, patches the pending create (like kanban tasks).
    private func enqueueContactUpdate(_ contact: CrmContactEntity) async throws {
        guard isSyncEnabled(), let syncOpDao, let syncMapper else { return }
        if let serverId = contact.serverId {
            try await syncOpDao.insert(
                SyncOpEntity(
                    entityType: "contact",
                    op: "update",
                    payloadJson: syncMapper.toUpdatePayload(contact),
                    serverId: serverId,
                    createdAt: ContactRepository.currentMillis()
                )
            )
            return
        }
        let pendingCreate = try await syncOpDao.listAll()
            .first { $0.entityType == "contact" && $0.op == "create" && $0.localId == contact.id }
        if var patched = pendingCreate {
            patched.payloadJson = syncMapper.toCreatePayload(contact)
            try await syncOpDao.insert(patched)
        }
    }

    private func enqueueContactDelete(_ contact: CrmContactEntity) async throws {
        // Kotlin only required the op queue here (deletes carry no payload).
        guard isSyncEnabled(), let syncOpDao else { return }
        if let serverId = contact.serverId {
            try await syncOpDao.insert(
                SyncOpEntity(
                    entityType: "contact",
                    op: "delete",
                    serverId: serverId,
                    createdAt: ContactRepository.currentMillis()
                )
            )
        } else {
            try await clearPendingContactOps(contact.id)
        }
    }

    private func clearPendingContactOps(_ localId: Int64) async throws {
        guard let syncOpDao else { return }
        let ops = try await syncOpDao.listAll()
            .filter { $0.entityType == "contact" && $0.localId == localId }
        for op in ops {
            try await syncOpDao.deleteById(op.id)
        }
    }
}

extension ContactCard {
    func toEntity(notes: String?, now: Int64) -> CrmContactEntity {
        CrmContactEntity(
            fullName: fullName,
            firstName: firstName,
            lastName: lastName,
            company: company,
            jobTitle: jobTitle,
            phones: phones,
            emails: emails,
            website: website,
            address: address,
            notes: notes,
            createdAt: now,
            updatedAt: now,
            phonesText: phones.joined(separator: " "),
            emailsText: emails.joined(separator: " ")
        )
    }
}

extension CrmContactEntity {
    func toCard() -> ContactCard {
        ContactCard(
            fullName: fullName,
            firstName: firstName,
            lastName: lastName,
            company: company,
            jobTitle: jobTitle,
            phones: phones,
            emails: emails,
            website: website,
            address: address,
            note: notes
        )
    }
}