AgendaSyncCoordinator.swift 514 lignes · 22146 octets
import Foundation

/// Reservation conflict (HTTP 409) awaiting a user decision: keep it or `abandonLocalReservation`.
struct AgendaConflict: Equatable {
    var localReservationId: Int64
    var message: String? = nil
}

/// Calendar↔local-store bridge and DTO↔entity mapping for RDV / reservations / unavailabilities.
/// Extracted from `SyncEngine` to keep the CRM+agenda orchestration readable; `SyncEngine`
/// remains the sole owner of the `SyncOpEntity` queue (push) and of the watermark.
final class AgendaSyncCoordinator {
    /// `CalendarBinding.kind` of the « Mes RDV » binding; also used as `SyncOpEntity.entityType`.
    static let kindRdv = "rdv"
    static let entityReservation = "reservation"
    private static let statutActive = "active"

    private let db: CrmDatabase

    init(db: CrmDatabase) {
        self.db = db
    }

    /// `salle:id,materiel:id,...` from non-`rdv` bindings that carry a `serverResourceId`.
    func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? {
        Self.ressourcesQuery(bindings)
    }

    /// Usable without an instance (status banner).
    static func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? {
        let parts = bindings
            .filter { $0.kind != kindRdv && $0.serverResourceId != nil }
            .map { "\($0.kind):\($0.serverResourceId!)" }
        return parts.isEmpty ? nil : parts.joined(separator: ",")
    }

    // ---- Calendar -> local store (events created/edited in the system calendar app) ----

    func agendaToRoom(bindings: [CalendarBinding], bridge: CalendarBridge) async throws {
        for binding in bindings {
            guard let calendarId = binding.androidCalendarId else { continue }
            if binding.kind == Self.kindRdv {
                try await agendaEventsToRdv(calendarId: calendarId, bridge: bridge)
            } else {
                try await agendaEventsToReservation(binding: binding, calendarId: calendarId, bridge: bridge)
            }
        }
    }

    private func agendaEventsToRdv(calendarId: Int64, bridge: CalendarBridge) async throws {
        let events = try bridge.listEvents(calendarId: calendarId)
        let presentEventIds = Set(events.compactMap { $0.eventId })

        for snap in events {
            guard let eventId = snap.eventId else { continue }
            let local = try await db.rdvDao.getByCalendarEventId(eventId)
            guard let local else {
                var entity = RdvEntity()
                entity.titre = snap.title
                entity.description = snap.descriptionBody
                entity.debutMs = snap.debutMs
                entity.finMs = snap.finMs
                entity.calendarEventId = eventId
                entity.updatedAt = Self.currentMillis()
                entity.dirtyLocal = true
                let localId = try await db.rdvDao.upsert(entity)
                try await enqueueOp(
                    entityType: Self.kindRdv,
                    op: "create",
                    localId: localId,
                    serverId: nil,
                    payloadJson: rdvPayload(entity)
                )
                continue
            }
            if !rdvDiffers(local, from: snap) { continue }
            var updated = local
            updated.titre = snap.title
            updated.description = snap.descriptionBody
            updated.debutMs = snap.debutMs
            updated.finMs = snap.finMs
            updated.updatedAt = Self.currentMillis()
            updated.dirtyLocal = true
            try await db.rdvDao.upsert(updated)
            try await enqueueOp(
                entityType: Self.kindRdv,
                op: local.serverId == nil ? "create" : "update",
                localId: local.localId,
                serverId: local.serverId,
                payloadJson: rdvPayload(updated)
            )
        }

        // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side).
        for rdv in try await db.rdvDao.listAll() {
            guard let eventId = rdv.calendarEventId else { continue }
            if presentEventIds.contains(eventId) { continue }
            if let serverId = rdv.serverId {
                try await enqueueOp(
                    entityType: Self.kindRdv,
                    op: "delete",
                    localId: rdv.localId,
                    serverId: serverId,
                    payloadJson: "{}"
                )
            }
            try await db.rdvDao.deleteByLocalId(rdv.localId)
        }
    }

    private func rdvDiffers(_ entity: RdvEntity, from snap: CalendarEventSnapshot) -> Bool {
        entity.titre != snap.title
            || entity.description != snap.descriptionBody
            || entity.debutMs != snap.debutMs
            || entity.finMs != snap.finMs
    }

    private func agendaEventsToReservation(
        binding: CalendarBinding,
        calendarId: Int64,
        bridge: CalendarBridge
    ) async throws {
        guard let cibleId = binding.serverResourceId else { return }
        let cibleType = binding.kind
        let events = try bridge.listEvents(calendarId: calendarId)
        let presentEventIds = Set(events.compactMap { $0.eventId })

        for snap in events {
            guard let eventId = snap.eventId else { continue }
            if CalendarEventTitles.isIndispoTitle(snap.title) { continue } // read-only mirror, no push
            let local = try await db.reservationDao.getByCalendarEventId(eventId)
            guard let local else {
                var entity = ReservationEntity()
                entity.cibleType = cibleType
                entity.cibleId = cibleId
                entity.debutMs = snap.debutMs
                entity.finMs = snap.finMs
                entity.motif = Self.motifReservation(snap)
                entity.calendarEventId = eventId
                entity.updatedAt = Self.currentMillis()
                entity.dirtyLocal = true
                let localId = try await db.reservationDao.upsert(entity)
                try await enqueueOp(
                    entityType: Self.entityReservation,
                    op: "create",
                    localId: localId,
                    serverId: nil,
                    payloadJson: reservationPayload(entity)
                )
                continue
            }
            if local.conflictPending { continue } // awaiting user decision, do not re-push
            if !reservationDiffers(local, from: snap) { continue }
            var updated = local
            updated.debutMs = snap.debutMs
            updated.finMs = snap.finMs
            updated.motif = Self.motifReservation(snap)
            updated.updatedAt = Self.currentMillis()
            updated.dirtyLocal = true
            try await db.reservationDao.upsert(updated)
            try await enqueueOp(
                entityType: Self.entityReservation,
                op: local.serverId == nil ? "create" : "update",
                localId: local.localId,
                serverId: local.serverId,
                payloadJson: reservationPayload(updated)
            )
        }

        // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side).
        for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
            guard let eventId = reservation.calendarEventId else { continue }
            if presentEventIds.contains(eventId) { continue }
            if reservation.conflictPending { continue } // user decision pending (see abandonLocalReservation)
            if let serverId = reservation.serverId {
                try await enqueueOp(
                    entityType: Self.entityReservation,
                    op: "delete",
                    localId: reservation.localId,
                    serverId: serverId,
                    payloadJson: cibleRefPayload(cibleType: cibleType, cibleId: cibleId)
                )
            }
            try await db.reservationDao.deleteByLocalId(reservation.localId)
        }
    }

    private func reservationDiffers(_ entity: ReservationEntity, from snap: CalendarEventSnapshot) -> Bool {
        entity.motif != Self.motifReservation(snap)
            || entity.debutMs != snap.debutMs
            || entity.finMs != snap.finMs
    }

    /// Motif of a pushed reservation: the event TITLE (symmetric with the downward
    /// direction, which displays the motif as title — see `roomToAgenda`), the
    /// description as fallback. The server refuses an empty motif.
    private static func motifReservation(_ snap: CalendarEventSnapshot) -> String {
        snap.title.isBlank ? snap.descriptionBody : snap.title
    }

    /// Replaces any pending op for this local entity before inserting the new one (avoids piling up).
    private func enqueueOp(
        entityType: String,
        op: String,
        localId: Int64,
        serverId: String?,
        payloadJson: String
    ) async throws {
        for pending in try await db.syncOpDao.listAll()
        where pending.entityType == entityType && pending.localId == localId {
            try await db.syncOpDao.deleteById(pending.id)
        }
        var entity = SyncOpEntity(entityType: entityType, op: op)
        entity.payloadJson = payloadJson
        entity.localId = localId
        entity.serverId = serverId
        entity.createdAt = Self.currentMillis()
        try await db.syncOpDao.insert(entity)
    }

    private func rdvPayload(_ entity: RdvEntity) -> String {
        let contactIds = (try? SyncJson.decode([String].self, from: entity.contactIdsJson)) ?? []
        return SyncJson.encodeToString(
            RdvUpsertRequest(
                titre: entity.titre,
                description: entity.description,
                lieu: entity.lieu,
                debut: epochMsToIso(entity.debutMs),
                fin: epochMsToIso(entity.finMs),
                contactIds: contactIds,
                projetId: entity.projetId
            )
        )
    }

    private func reservationPayload(_ entity: ReservationEntity) -> String {
        SyncJson.encodeToString(
            ReservationUpsertRequest(
                debut: epochMsToIso(entity.debutMs),
                fin: epochMsToIso(entity.finMs),
                motif: entity.motif
            )
        )
    }

    /// Payload of a reservation `delete` SyncOp: carries `(cibleType, cibleId)` so that
    /// `SyncEngine` can resolve the server genre/id even after the local entity is gone
    /// (see `resolveReservationCible`). Reuses `CibleRessourceDto`, already used for `cible`.
    private func cibleRefPayload(cibleType: String, cibleId: String) -> String {
        SyncJson.encodeToString(CibleRessourceDto(type: cibleType, id: cibleId))
    }

    // ---- Local store -> calendar (after applying the pull) ----

    func roomToAgenda(bindings: [CalendarBinding], bridge: CalendarBridge) async throws {
        for binding in bindings {
            guard let calendarId = binding.androidCalendarId else { continue }
            if binding.kind == Self.kindRdv {
                try await rdvToAgenda(calendarId: calendarId, bridge: bridge)
            } else {
                try await resourceEntitiesToAgenda(binding: binding, calendarId: calendarId, bridge: bridge)
            }
        }
    }

    private func rdvToAgenda(calendarId: Int64, bridge: CalendarBridge) async throws {
        for rdv in try await db.rdvDao.listAll() {
            let eventId = try bridge.upsertEvent(
                calendarId: calendarId,
                snap: CalendarEventSnapshot(
                    title: rdv.titre,
                    debutMs: rdv.debutMs,
                    finMs: rdv.finMs,
                    eventId: rdv.calendarEventId,
                    serverId: rdv.serverId,
                    descriptionBody: rdv.description
                )
            )
            if eventId != rdv.calendarEventId {
                var updated = rdv
                updated.calendarEventId = eventId
                try await db.rdvDao.upsert(updated)
            }
        }
        try deleteOrphanEvents(calendarId: calendarId, bridge: bridge, kept: try await rdvKeptIds())
    }

    private func resourceEntitiesToAgenda(
        binding: CalendarBinding,
        calendarId: Int64,
        bridge: CalendarBridge
    ) async throws {
        guard let cibleId = binding.serverResourceId else { return }
        let cibleType = binding.kind
        for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
            if reservation.statut != Self.statutActive { continue }
            let eventId = try bridge.upsertEvent(
                calendarId: calendarId,
                snap: CalendarEventSnapshot(
                    title: reservation.motif.isEmpty ? "Réservation" : reservation.motif,
                    debutMs: reservation.debutMs,
                    finMs: reservation.finMs,
                    eventId: reservation.calendarEventId,
                    serverId: reservation.serverId
                )
            )
            if eventId != reservation.calendarEventId {
                var updated = reservation
                updated.calendarEventId = eventId
                try await db.reservationDao.upsert(updated)
            }
        }
        for indispo in try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
            let baseTitle = nonBlank(indispo.commentaire) ?? indispo.nature ?? "Indisponible"
            let eventId = try bridge.upsertEvent(
                calendarId: calendarId,
                snap: CalendarEventSnapshot(
                    title: CalendarEventTitles.indispoTitle(baseTitle),
                    debutMs: indispo.debutMs,
                    finMs: indispo.finMs,
                    eventId: indispo.calendarEventId,
                    serverId: indispo.serverId
                )
            )
            if eventId != indispo.calendarEventId {
                var updated = indispo
                updated.calendarEventId = eventId
                try await db.indisponibiliteDao.upsert(updated)
            }
        }
        try deleteOrphanEvents(
            calendarId: calendarId,
            bridge: bridge,
            kept: try await resourceKeptIds(cibleType: cibleType, cibleId: cibleId)
        )
    }

    private func nonBlank(_ value: String?) -> String? {
        guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
        return value
    }

    // ---- Orphan events (local store deleted the entity, the calendar event must follow) ----

    private func rdvKeptIds() async throws -> (Set<String>, Set<Int64>) {
        let entities = try await db.rdvDao.listAll()
        return (
            Set(entities.compactMap { $0.serverId }),
            Set(entities.compactMap { $0.calendarEventId })
        )
    }

    private func resourceKeptIds(cibleType: String, cibleId: String) async throws -> (Set<String>, Set<Int64>) {
        let reservations = try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId)
            .filter { $0.statut == Self.statutActive }
        let indispos = try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId)
        let serverIds = Set(reservations.compactMap { $0.serverId }).union(indispos.compactMap { $0.serverId })
        let eventIds = Set(reservations.compactMap { $0.calendarEventId }).union(indispos.compactMap { $0.calendarEventId })
        return (serverIds, eventIds)
    }

    /// `deleteEvent` for every `card2vcf:serverId=`-tagged event (see `CalendarServerIdCodec`)
    /// of `calendarId` whose `serverId` and `eventId` no longer match a local entity (`kept`).
    /// Untagged events (not yet pushed, or foreign to card2vcf) are never deleted.
    private func deleteOrphanEvents(
        calendarId: Int64,
        bridge: CalendarBridge,
        kept: (Set<String>, Set<Int64>)
    ) throws {
        for eventId in try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept) {
            try bridge.deleteEvent(eventId: eventId)
        }
    }

    private func orphanEvents(
        calendarId: Int64,
        bridge: CalendarBridge,
        kept: (Set<String>, Set<Int64>)
    ) throws -> [Int64] {
        let (keptServerIds, keptEventIds) = kept
        return try bridge.listEvents(calendarId: calendarId).compactMap { snap in
            guard let eventId = snap.eventId else { return nil }
            guard let serverId = snap.serverId else { return nil } // not card2vcf-tagged: never managed here
            return (keptServerIds.contains(serverId) || keptEventIds.contains(eventId)) ? nil : eventId
        }
    }

    /// Number of card2vcf-tagged events in the bound calendars with no matching local entity.
    func orphanEventCount(bindings: [CalendarBinding], bridge: CalendarBridge) async throws -> Int {
        var count = 0
        for binding in bindings {
            guard let calendarId = binding.androidCalendarId else { continue }
            let kept: (Set<String>, Set<Int64>)
            if binding.kind == Self.kindRdv {
                kept = try await rdvKeptIds()
            } else {
                guard let cibleId = binding.serverResourceId else { continue }
                kept = try await resourceKeptIds(cibleType: binding.kind, cibleId: cibleId)
            }
            count += try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept).count
        }
        return count
    }

    // ---- Pull: DTO -> entities (LWW RDV, active reservations, read-only unavailabilities) ----

    func applyRdvPull(_ dto: RendezVousDto) async throws {
        let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
        let local = try await db.rdvDao.getByServerId(dto.id)
        guard let local else {
            var entity = RdvEntity()
            entity.serverId = dto.id
            entity.titre = dto.titre
            entity.description = dto.description
            entity.lieu = dto.lieu
            entity.debutMs = parseIsoToEpochMs(dto.debut)
            entity.finMs = parseIsoToEpochMs(dto.fin)
            entity.contactIdsJson = SyncJson.encodeToString(dto.contactIds)
            entity.projetId = dto.projetId
            entity.updatedAt = remoteTs
            try await db.rdvDao.upsert(entity)
            return
        }
        var remoteEntity = local
        remoteEntity.titre = dto.titre
        remoteEntity.description = dto.description
        remoteEntity.lieu = dto.lieu
        remoteEntity.debutMs = parseIsoToEpochMs(dto.debut)
        remoteEntity.finMs = parseIsoToEpochMs(dto.fin)
        remoteEntity.contactIdsJson = SyncJson.encodeToString(dto.contactIds)
        remoteEntity.projetId = dto.projetId
        remoteEntity.updatedAt = remoteTs
        remoteEntity.dirtyLocal = false
        let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
        if merged != local {
            try await db.rdvDao.upsert(merged)
        }
    }

    func applyReservationPull(_ dto: ReservationDto) async throws {
        let local = try await db.reservationDao.getByServerId(dto.id)
        if dto.statut != Self.statutActive {
            if local != nil {
                try await db.reservationDao.deleteByServerId(dto.id)
            }
            return
        }
        let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
        guard let local else {
            var entity = ReservationEntity()
            entity.serverId = dto.id
            entity.cibleType = dto.cible.type
            entity.cibleId = dto.cible.id
            entity.debutMs = parseIsoToEpochMs(dto.debut)
            entity.finMs = parseIsoToEpochMs(dto.fin)
            entity.motif = dto.motif
            entity.statut = dto.statut
            entity.updatedAt = remoteTs
            try await db.reservationDao.upsert(entity)
            return
        }
        var remoteEntity = local
        remoteEntity.cibleType = dto.cible.type
        remoteEntity.cibleId = dto.cible.id
        remoteEntity.debutMs = parseIsoToEpochMs(dto.debut)
        remoteEntity.finMs = parseIsoToEpochMs(dto.fin)
        remoteEntity.motif = dto.motif
        remoteEntity.statut = dto.statut
        remoteEntity.updatedAt = remoteTs
        remoteEntity.dirtyLocal = false
        remoteEntity.conflictPending = false
        let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
        if merged != local {
            try await db.reservationDao.upsert(merged)
        }
    }

    func applyIndisponibilitePull(_ dto: IndisponibiliteDto) async throws {
        let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
        let local = try await db.indisponibiliteDao.getByServerId(dto.id)
        var entity = local ?? {
            var fresh = IndisponibiliteEntity()
            fresh.serverId = dto.id
            return fresh
        }()
        entity.cibleType = dto.cible.type
        entity.cibleId = dto.cible.id
        entity.debutMs = parseIsoToEpochMs(dto.debut)
        entity.finMs = parseIsoToEpochMs(dto.fin)
        entity.nature = dto.nature
        entity.commentaire = dto.commentaire
        entity.updatedAt = remoteTs
        try await db.indisponibiliteDao.upsert(entity)
    }

    @discardableResult
    func applyAgendaTombstone(_ dto: TombstoneDto) async throws -> Bool {
        switch dto.entityType {
        case "rdv":
            try await db.rdvDao.deleteByServerId(dto.id)
        case "reservation":
            try await db.reservationDao.deleteByServerId(dto.id)
        case "indisponibilite":
            try await db.indisponibiliteDao.deleteByServerId(dto.id)
        default:
            return false
        }
        return true
    }

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