SettingsViewModel.swift 432 lignes · 17168 octets
import Foundation

// Port of ui/settings/SettingsViewModel.kt.

/// « Ressource » toggle (salle/matériel/véhicule) shown in Paramètres, derived from
/// the server catalogue + local bindings.
struct RessourceToggleState: Equatable {
    var kind: String
    var serverResourceId: String
    var nom: String
    var displayName: String
    var actif: Bool
    var linked: Bool
}

/// Calendar binding awaiting confirmation before removal (see `SettingsViewModel.confirmRemoveBinding`).
struct PendingCalendarRemoval: Equatable {
    var kind: String
    var serverResourceId: String?
    var displayName: String
}

/// Activation awaiting the user's choice when iCloud hides the local calendar
/// source: create the calendar in the account source (iCloud), or give up.
struct PendingICloudActivation: Equatable {
    var kind: String
    var serverResourceId: String?
    var displayName: String
}

/// Paramètres screen state: logged in, or login form (+ pending retype).
enum SettingsUiState {
    struct LoggedIn {
        var userName: String
        var baseUrl: String
        var calendarPermissionGranted = false
        var mesRdvLinked = false
        var ressources: [RessourceToggleState] = []
        var catalogueLoading = false
        var catalogueError: String? = nil
        var pendingRemoval: PendingCalendarRemoval? = nil
        var pendingICloudActivation: PendingICloudActivation? = nil
        var modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription
        var speechRecognitionDisponible: Bool = PreferencesAudio.speechRecognitionDisponible
    }

    struct LoggedOut {
        var baseUrl = ""
        var userName = ""
        var password = ""
        /// When true, also accepts `http://` (dev / internal / demo). Default false = HTTPS only.
        var allowCleartextHttp = false
        var loading = false
        var error: String? = nil
        var pendingAuth: AuthCleResponse? = nil
        var retypePassword = ""
        var retypeError: String? = nil
    }

    case loggedIn(LoggedIn)
    case loggedOut(LoggedOut)
}

/// API-key login: `obtainKey` calls `/api/auth/cle`, `confirmRetype` decrypts and stores
/// (key persisted, the password itself never is). Once connected, also drives the
/// Calendriers section: permission, « Mes RDV » binding and active resources (server
/// catalogue), with a confirmation dialog before removing a binding.
@MainActor
final class SettingsViewModel: ObservableObject {
    static let KIND_SALLE = "salle"
    static let KIND_MATERIEL = "materiel"
    static let KIND_VEHICULE = "vehicule"
    static let MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV"

    /// Shown when the calendar deletion fails (Agenda permission revoked meanwhile).
    static let CALENDAR_DELETE_ERROR =
        "Calendrier non supprimé : autorisation Agenda manquante. La liaison a bien été retirée."

    @Published private(set) var state: SettingsUiState

    private let credentialsStore: SyncCredentialsStore
    private let calendarBindingsStore: CalendarBindingsStore
    private let calendarBridge: CalendarBridge
    private let apiFactory: (String) -> AilianceApi
    private let authenticatedApiFactory: (String, String) -> AilianceApi

    init(
        credentialsStore: SyncCredentialsStore = SyncCredentialsStore(),
        calendarBindingsStore: CalendarBindingsStore = CalendarBindingsStore(),
        calendarBridge: CalendarBridge = EventKitCalendarBridge(),
        apiFactory: @escaping (String) -> AilianceApi = { baseUrl in
            AilianceApiClient(baseUrl: baseUrl)
        },
        authenticatedApiFactory: @escaping (String, String) -> AilianceApi = { baseUrl, apiKey in
            AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
        }
    ) {
        self.credentialsStore = credentialsStore
        self.calendarBindingsStore = calendarBindingsStore
        self.calendarBridge = calendarBridge
        self.apiFactory = apiFactory
        self.authenticatedApiFactory = authenticatedApiFactory

        if credentialsStore.isConfigured(),
           let baseUrl = credentialsStore.baseUrl,
           let userName = credentialsStore.userName {
            state = .loggedIn(SettingsUiState.LoggedIn(
                userName: userName,
                baseUrl: baseUrl,
                mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv }
            ))
        } else {
            state = .loggedOut(SettingsUiState.LoggedOut(
                baseUrl: credentialsStore.baseUrl ?? "",
                allowCleartextHttp: credentialsStore.allowCleartextHttp
            ))
        }
    }

    // ---- Login form ----

    func setBaseUrl(_ value: String) {
        updateLoggedOut { $0.baseUrl = value; $0.error = nil }
    }

    func setUserName(_ value: String) {
        updateLoggedOut { $0.userName = value; $0.error = nil }
    }

    func setPassword(_ value: String) {
        updateLoggedOut { $0.password = value; $0.error = nil }
    }

    func setRetypePassword(_ value: String) {
        updateLoggedOut { $0.retypePassword = value; $0.retypeError = nil }
    }

    func setAllowCleartextHttp(_ value: Bool) {
        credentialsStore.allowCleartextHttp = value
        updateLoggedOut { $0.allowCleartextHttp = value; $0.error = nil }
    }

    func obtainKey() {
        guard case .loggedOut(let s) = state else { return }
        let baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
        let userName = s.userName.trimmingCharacters(in: .whitespacesAndNewlines)
        if baseUrl.isBlank || userName.isBlank || s.password.isBlank {
            updateLoggedOut { $0.error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe" }
            return
        }
        if let err = ServerUrlPolicy.validate(baseUrl, allowCleartext: s.allowCleartextHttp) {
            updateLoggedOut { $0.error = err }
            return
        }
        Task {
            updateLoggedOut { $0.loading = true; $0.error = nil }
            let result = await apiFactory(baseUrl).authCle(nom: userName, motDePasse: s.password)
            guard case .loggedOut(var current) = state else { return }
            switch result {
            case .ok(let value):
                current.loading = false
                current.password = ""
                current.pendingAuth = value
                current.error = nil
            case .err(let code, let message):
                current.loading = false
                current.password = ""
                current.error = Self.errorMessageFor(code: code, message: message)
            }
            state = .loggedOut(current)
        }
    }

    func confirmRetype() {
        guard case .loggedOut(let s) = state, let auth = s.pendingAuth else { return }
        if s.retypePassword.isBlank {
            updateLoggedOut { $0.retypeError = "Mot de passe requis" }
            return
        }
        let retypePassword = s.retypePassword
        Task {
            // Argon2id KDF (19 MiB, t=2) is CPU-heavy — run it off the main actor.
            let apiKey: String? = await Task.detached(priority: .userInitiated) {
                try? ApiCleCrypto.decryptApiKey(
                    password: retypePassword,
                    saltB64: auth.sel,
                    cipherB64: auth.cleChiffree
                )
            }.value
            guard case .loggedOut(var current) = state else { return }
            guard let apiKey else {
                current.retypePassword = ""
                current.retypeError = "Mot de passe incorrect"
                state = .loggedOut(current)
                return
            }
            let baseUrl = ServerUrlPolicy.normalizeBaseUrl(current.baseUrl)
            credentialsStore.save(baseUrl: baseUrl, apiKey: apiKey, userName: auth.nom)
            state = .loggedIn(SettingsUiState.LoggedIn(
                userName: auth.nom,
                baseUrl: baseUrl,
                mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv }
            ))
        }
    }

    func cancelRetype() {
        updateLoggedOut { $0.pendingAuth = nil; $0.retypePassword = ""; $0.retypeError = nil }
    }

    func disconnect() {
        credentialsStore.clear()
        state = .loggedOut(SettingsUiState.LoggedOut(
            allowCleartextHttp: credentialsStore.allowCleartextHttp
        ))
    }

    // ---- Calendriers ----

    /// Called by the screen after the EventKit access request (Android runtime
    /// permission `READ_CALENDAR`/`WRITE_CALENDAR` equivalent).
    func onCalendarPermissionChanged(granted: Bool) {
        updateLoggedIn { $0.calendarPermissionGranted = granted }
        if granted { refreshRessourcesCatalogue() }
    }

    func setMesRdvEnabled(_ enabled: Bool) {
        guard case .loggedIn = state else { return }
        if enabled {
            requestActivation(
                kind: AgendaSyncCoordinator.kindRdv,
                serverResourceId: nil,
                displayName: Self.MES_RDV_DISPLAY_NAME
            )
        } else {
            updateLoggedIn {
                $0.pendingRemoval = PendingCalendarRemoval(
                    kind: AgendaSyncCoordinator.kindRdv,
                    serverResourceId: nil,
                    displayName: Self.MES_RDV_DISPLAY_NAME
                )
            }
        }
    }

    func setRessourceEnabled(kind: String, serverResourceId: String, enabled: Bool) {
        guard case .loggedIn(let s) = state else { return }
        guard let item = s.ressources.first(where: { $0.kind == kind && $0.serverResourceId == serverResourceId }),
              item.actif else {
            return
        }
        if enabled {
            requestActivation(kind: kind, serverResourceId: serverResourceId, displayName: item.displayName)
        } else {
            updateLoggedIn {
                $0.pendingRemoval = PendingCalendarRemoval(
                    kind: kind,
                    serverResourceId: serverResourceId,
                    displayName: item.displayName
                )
            }
        }
    }

    /// Activates directly when the local source exists; otherwise defers to the
    /// « iCloud ou rien » user choice (`confirmICloudActivation` / `cancelICloudActivation`).
    private func requestActivation(kind: String, serverResourceId: String?, displayName: String) {
        guard calendarBridge.hasLocalSource() else {
            updateLoggedIn {
                $0.pendingICloudActivation = PendingICloudActivation(
                    kind: kind,
                    serverResourceId: serverResourceId,
                    displayName: displayName
                )
            }
            return
        }
        activateBinding(kind: kind, serverResourceId: serverResourceId, displayName: displayName)
    }

    /// « Utiliser iCloud » : the calendar is created in the account source.
    func confirmICloudActivation() {
        guard case .loggedIn(let s) = state, let pending = s.pendingICloudActivation else { return }
        updateLoggedIn { $0.pendingICloudActivation = nil }
        activateBinding(
            kind: pending.kind,
            serverResourceId: pending.serverResourceId,
            displayName: pending.displayName
        )
    }

    /// « Annuler » : the toggle stays off, nothing is created.
    func cancelICloudActivation() {
        updateLoggedIn { $0.pendingICloudActivation = nil }
    }

    private func activateBinding(kind: String, serverResourceId: String?, displayName: String) {
        guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: displayName) else {
            return
        }
        calendarBindingsStore.upsert(CalendarBinding(
            kind: kind,
            serverResourceId: serverResourceId,
            displayName: displayName,
            androidCalendarId: calendarId
        ))
        updateLoggedIn { current in
            if kind == AgendaSyncCoordinator.kindRdv {
                current.mesRdvLinked = true
            } else if let serverResourceId {
                current.ressources = current.ressources.map {
                    $0.withLinked(kind: kind, serverResourceId: serverResourceId, linked: true)
                }
            }
        }
    }

    /// Confirms the binding removal (« Retirer » in the confirmation dialog): deletes the
    /// local calendar from the device (`CalendarBridge.deleteCalendar`, events cascade)
    /// **then** removes the binding (`CalendarBindingsStore.remove`). Server data (RDV,
    /// réservations) is untouched. If the deletion fails (Agenda permission revoked
    /// meanwhile), the binding is removed anyway and the user is told via `catalogueError`.
    func confirmRemoveBinding() {
        guard case .loggedIn(let s) = state, let pending = s.pendingRemoval else { return }
        let calendarId = calendarBindingsStore.list()
            .first { $0.kind == pending.kind && $0.serverResourceId == pending.serverResourceId }?
            .androidCalendarId
        var deleted = true
        if let calendarId {
            do {
                try calendarBridge.deleteCalendar(calendarId: calendarId)
            } catch {
                deleted = false
            }
        }
        calendarBindingsStore.remove(kind: pending.kind, serverResourceId: pending.serverResourceId)
        updateLoggedIn { current in
            if pending.kind == AgendaSyncCoordinator.kindRdv {
                current.mesRdvLinked = false
            } else {
                current.ressources = current.ressources.map {
                    $0.withLinked(kind: pending.kind, serverResourceId: pending.serverResourceId, linked: false)
                }
            }
            current.pendingRemoval = nil
            if !deleted {
                current.catalogueError = Self.CALENDAR_DELETE_ERROR
            }
        }
    }

    func dismissRemoveBinding() {
        updateLoggedIn { $0.pendingRemoval = nil }
    }

    func setModeTranscription(_ mode: ModeTranscription) {
        PreferencesAudio.modeTranscription = mode
        updateLoggedIn { $0.modeTranscription = mode }
    }

    private func refreshRessourcesCatalogue() {
        guard case .loggedIn = state else { return }
        guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else { return }
        updateLoggedIn { $0.catalogueLoading = true; $0.catalogueError = nil }
        Task {
            let result = await authenticatedApiFactory(baseUrl, apiKey).getRessourcesCatalogue()
            switch result {
            case .ok(let catalogue):
                let bindings = calendarBindingsStore.list()
                updateLoggedIn {
                    $0.ressources = Self.buildRessourceToggles(catalogue: catalogue, bindings: bindings)
                    $0.catalogueLoading = false
                }
            case .err(_, let message):
                updateLoggedIn { $0.catalogueLoading = false; $0.catalogueError = message }
            }
        }
    }

    static func buildRessourceToggles(
        catalogue: RessourcesCatalogueDto,
        bindings: [CalendarBinding]
    ) -> [RessourceToggleState] {
        func toggles(kind: String, items: [RessourceItemDto], label: String) -> [RessourceToggleState] {
            items.map { item in
                RessourceToggleState(
                    kind: kind,
                    serverResourceId: item.id,
                    nom: item.nom,
                    displayName: "PicLead — \(label) \(item.nom)",
                    actif: item.actif,
                    linked: bindings.contains { $0.kind == kind && $0.serverResourceId == item.id }
                )
            }
        }
        return toggles(kind: KIND_SALLE, items: catalogue.salles, label: "Salle")
            + toggles(kind: KIND_MATERIEL, items: catalogue.materiels, label: "Matériel")
            + toggles(kind: KIND_VEHICULE, items: catalogue.vehicules, label: "Véhicule")
    }

    // ---- Helpers ----

    private func updateLoggedOut(_ transform: (inout SettingsUiState.LoggedOut) -> Void) {
        guard case .loggedOut(var s) = state else { return }
        transform(&s)
        state = .loggedOut(s)
    }

    private func updateLoggedIn(_ transform: (inout SettingsUiState.LoggedIn) -> Void) {
        guard case .loggedIn(var s) = state else { return }
        transform(&s)
        state = .loggedIn(s)
    }

    static func errorMessageFor(code: Int, message: String) -> String {
        switch code {
        case 401: return "Identifiants invalides"
        case 404: return "Aucune clé API pour cet utilisateur"
        case -1: return "Erreur réseau : \(message)"
        default: return message
        }
    }
}

private extension RessourceToggleState {
    func withLinked(kind: String, serverResourceId: String?, linked: Bool) -> RessourceToggleState {
        guard self.kind == kind, self.serverResourceId == serverResourceId else { return self }
        var copy = self
        copy.linked = linked
        return copy
    }
}