AilianceApiClient.swift 453 lignes · 18543 octets
import Foundation

/// URLSession port of the OkHttp `AilianceApiClient`:
/// Bearer auth (except `/api/auth/cle`), JSON bodies, multipart uploads,
/// typed `ApiResult` error mapping (HTTP code + parsed `{message|error}` body).
final class AilianceApiClient: AilianceApi {
    enum ApiResult<T> {
        case ok(T)
        case err(code: Int, message: String)

        var isOk: Bool {
            if case .ok = self { return true }
            return false
        }

        var value: T? {
            if case .ok(let value) = self { return value }
            return nil
        }
    }

    private let base: String
    private let apiKey: String?
    private let session: URLSession

    init(baseUrl: String, apiKey: String? = nil, session: URLSession = AilianceApiClient.defaultSession()) {
        var trimmed = baseUrl
        while trimmed.hasSuffix("/") { trimmed.removeLast() }
        self.base = trimmed
        self.apiKey = apiKey
        self.session = session
    }

    /// No system proxy (OkHttp `Proxy.NO_PROXY` equivalent) ; read timeout 30 s.
    static func defaultSession() -> URLSession {
        let config = URLSessionConfiguration.ephemeral
        config.timeoutIntervalForRequest = 30
        config.connectionProxyDictionary = [:]
        return URLSession(configuration: config)
    }

    // MARK: - AilianceApi

    func authCle(nom: String, motDePasse: String) async -> ApiResult<AuthCleResponse> {
        let body = SyncJson.encodeToString(AuthCleRequest(nom: nom, motDePasse: motDePasse))
        return await post("/api/auth/cle", body, useBearer: false) { data in
            try SyncJson.decoder().decode(AuthCleResponse.self, from: data)
        }
    }

    func syncStatus(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncStatusResponse> {
        await get("/api/sync/status?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in
            try SyncJson.decoder().decode(SyncStatusResponse.self, from: data)
        }
    }

    func syncPull(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncPullResponse> {
        await get("/api/sync/pull?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in
            try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
        }
    }

    func createContact(jsonBody: String) async -> ApiResult<String> {
        await post("/api/contacts", jsonBody, parse: Self.text)
    }

    func updateContact(id: String, jsonBody: String) async -> ApiResult<String> {
        await put("/api/contacts/\(Self.encodePath(id))", jsonBody, parse: Self.text)
    }

    func deleteContact(id: String) async -> ApiResult<Void> {
        await delete("/api/contacts/\(Self.encodePath(id))")
    }

    func createEntreprise(jsonBody: String) async -> ApiResult<String> {
        await post("/api/entreprises", jsonBody, parse: Self.text)
    }

    func updateEntreprise(id: String, jsonBody: String) async -> ApiResult<String> {
        await put("/api/entreprises/\(Self.encodePath(id))", jsonBody, parse: Self.text)
    }

    func deleteEntreprise(id: String) async -> ApiResult<Void> {
        await delete("/api/entreprises/\(Self.encodePath(id))")
    }

    func createProjet(jsonBody: String) async -> ApiResult<String> {
        await post("/api/projets", jsonBody, parse: Self.text)
    }

    func updateProjet(id: String, jsonBody: String) async -> ApiResult<String> {
        await put("/api/projets/\(Self.encodePath(id))", jsonBody, parse: Self.text)
    }

    func deleteProjet(id: String) async -> ApiResult<Void> {
        await delete("/api/projets/\(Self.encodePath(id))")
    }

    func createTache(projetId: String, jsonBody: String) async -> ApiResult<String> {
        await post("/api/projets/\(Self.encodePath(projetId))/taches", jsonBody, parse: Self.text)
    }

    func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> {
        await put("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))", jsonBody, parse: Self.text)
    }

    func deleteTache(projetId: String, tacheId: String) async -> ApiResult<Void> {
        await delete("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))")
    }

    func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> {
        await post(
            "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))/deplacer",
            jsonBody,
            parse: Self.text
        )
    }

    func toggleSousTache(
        projetId: String,
        tacheId: String,
        sousTacheId: String
    ) async -> ApiResult<String> {
        await put(
            "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))"
                + "/sous-taches/\(Self.encodePath(sousTacheId))",
            "",
            parse: Self.text
        )
    }

    func createInteraction(contactId: String, jsonBody: String) async -> ApiResult<String> {
        await post("/api/contacts/\(Self.encodePath(contactId))/interactions", jsonBody, parse: Self.text)
    }

    func listRdv() async -> ApiResult<[RendezVousDto]> {
        await get("/api/rdv") { data in
            try SyncJson.decoder().decode([RendezVousDto].self, from: data)
        }
    }

    func createRdv(jsonBody: String) async -> ApiResult<String> {
        await post("/api/rdv", jsonBody, parse: Self.text)
    }

    func getRdv(id: String) async -> ApiResult<RendezVousDto> {
        await get("/api/rdv/\(Self.encodePath(id))") { data in
            try SyncJson.decoder().decode(RendezVousDto.self, from: data)
        }
    }

    func updateRdv(id: String, jsonBody: String) async -> ApiResult<String> {
        await put("/api/rdv/\(Self.encodePath(id))", jsonBody, parse: Self.text)
    }

    func deleteRdv(id: String) async -> ApiResult<Void> {
        await delete("/api/rdv/\(Self.encodePath(id))")
    }

    func getRessourcesCatalogue() async -> ApiResult<RessourcesCatalogueDto> {
        await get("/api/ressources") { data in
            try SyncJson.decoder().decode(RessourcesCatalogueDto.self, from: data)
        }
    }

    func listReservations(genre: String, resourceId: String) async -> ApiResult<[ReservationDto]> {
        await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations") { data in
            try SyncJson.decoder().decode([ReservationDto].self, from: data)
        }
    }

    func createReservation(genre: String, resourceId: String, jsonBody: String) async -> ApiResult<String> {
        await post(
            "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations",
            jsonBody,
            parse: Self.text
        )
    }

    func updateReservation(
        genre: String,
        resourceId: String,
        reservationId: String,
        jsonBody: String
    ) async -> ApiResult<String> {
        await put(
            "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))",
            jsonBody,
            parse: Self.text
        )
    }

    func deleteReservation(genre: String, resourceId: String, reservationId: String) async -> ApiResult<Void> {
        await delete(
            "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))"
        )
    }

    func listIndisponibilites(genre: String, resourceId: String) async -> ApiResult<[IndisponibiliteDto]> {
        await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/indisponibilites") { data in
            try SyncJson.decoder().decode([IndisponibiliteDto].self, from: data)
        }
    }

    func uploadContactCarte(
        id: String,
        bytes: Data,
        filename: String,
        contentType: String
    ) async -> ApiResult<String> {
        await uploadMultipart("/api/contacts/\(Self.encodePath(id))/carte", bytes, filename, contentType)
    }

    func uploadContactPhoto(
        id: String,
        bytes: Data,
        filename: String,
        contentType: String
    ) async -> ApiResult<String> {
        await uploadMultipart("/api/contacts/\(Self.encodePath(id))/photo", bytes, filename, contentType)
    }

    func downloadContactCarte(id: String) async -> ApiResult<Data> {
        await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/carte", nil, useBearer: true))
    }

    func downloadContactPhoto(id: String) async -> ApiResult<Data> {
        await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/photo", nil, useBearer: true))
    }

    func uploadInteractionAudio(
        contactId: String,
        interactionId: String,
        bytes: Data,
        filename: String
    ) async -> ApiResult<String> {
        let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio"
        return await uploadMultipart(path, bytes, filename, "audio/wav")
    }

    func downloadInteractionAudio(contactId: String, interactionId: String) async -> ApiResult<Data> {
        let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio"
        return await executeBytes(buildRequest("GET", path, nil, useBearer: true))
    }

    func createNoteProjet(projetId: String, jsonBody: String) async -> ApiResult<String> {
        await post("/api/projets/\(Self.encodePath(projetId))/notes", jsonBody, parse: Self.text)
    }

    func uploadNoteProjetAudio(
        projetId: String,
        noteId: String,
        bytes: Data,
        filename: String
    ) async -> ApiResult<String> {
        let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio"
        return await uploadMultipart(path, bytes, filename, "audio/wav")
    }

    func downloadNoteProjetAudio(projetId: String, noteId: String) async -> ApiResult<Data> {
        let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio"
        return await executeBytes(buildRequest("GET", path, nil, useBearer: true))
    }

    func deleteInteraction(interactionId: String) async -> ApiResult<Void> {
        await delete("/api/interactions/\(Self.encodePath(interactionId))")
    }

    func deleteNoteProjet(projetId: String, noteId: String) async -> ApiResult<Void> {
        await delete("/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))")
    }

    func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> ApiResult<String> {
        let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/transcription"
        return await post(path, "{}", parse: Self.text)
    }

    func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> ApiResult<String> {
        let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/transcription"
        return await post(path, "{}", parse: Self.text)
    }

    // MARK: - Internals

    private func uploadMultipart(
        _ path: String,
        _ bytes: Data,
        _ filename: String,
        _ contentType: String
    ) async -> ApiResult<String> {
        let boundary = "Card2vcf-\(UUID().uuidString)"
        var body = Data()
        body.append(Data("--\(boundary)\r\n".utf8))
        body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
        body.append(Data("Content-Type: \(contentType)\r\n\r\n".utf8))
        body.append(bytes)
        body.append(Data("\r\n--\(boundary)--\r\n".utf8))

        guard var request = makeRequest(path) else {
            return .err(code: -1, message: "URL invalide : \(base)\(path)")
        }
        request.httpMethod = "POST"
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
        request.httpBody = body
        if let apiKey, !apiKey.isEmpty {
            request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        }
        return await execute(request, parse: Self.text)
    }

    private func executeBytes(_ request: URLRequest?) async -> ApiResult<Data> {
        guard let request else { return .err(code: -1, message: "URL invalide") }
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                return .err(code: -1, message: "Réponse invalide")
            }
            if (200..<300).contains(http.statusCode) {
                return .ok(data)
            }
            let text = String(data: data, encoding: .utf8) ?? ""
            return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http)))
        } catch {
            return .err(code: -1, message: networkErrorMessage(error, request: request))
        }
    }

    private func get<T>(_ path: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> {
        await execute(buildRequest("GET", path, nil, useBearer: true), parse: parse)
    }

    private func post<T>(
        _ path: String,
        _ jsonBody: String,
        useBearer: Bool = true,
        parse: @escaping (Data) throws -> T
    ) async -> ApiResult<T> {
        await execute(buildRequest("POST", path, jsonBody, useBearer: useBearer), parse: parse)
    }

    private func put<T>(_ path: String, _ jsonBody: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> {
        await execute(buildRequest("PUT", path, jsonBody, useBearer: true), parse: parse)
    }

    private func delete(_ path: String) async -> ApiResult<Void> {
        let result = await execute(buildRequest("DELETE", path, nil, useBearer: true), parse: Self.text)
        switch result {
        case .ok:
            return .ok(())
        case .err(let code, let message):
            return .err(code: code, message: message)
        }
    }

    private func makeRequest(_ path: String) -> URLRequest? {
        guard let url = URL(string: "\(base)\(path)") else { return nil }
        return URLRequest(url: url)
    }

    private func buildRequest(_ method: String, _ path: String, _ jsonBody: String?, useBearer: Bool) -> URLRequest? {
        guard var request = makeRequest(path) else { return nil }
        request.httpMethod = method
        if useBearer, let apiKey, !apiKey.isEmpty {
            request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        }
        if let jsonBody {
            request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
            request.httpBody = Data(jsonBody.utf8)
        }
        return request
    }

    private func execute<T>(_ request: URLRequest?, parse: (Data) throws -> T) async -> ApiResult<T> {
        guard let request else { return .err(code: -1, message: "URL invalide") }
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                return .err(code: -1, message: "Réponse invalide")
            }
            if (200..<300).contains(http.statusCode) {
                return .ok(try parse(data))
            }
            let text = String(data: data, encoding: .utf8) ?? ""
            return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http)))
        } catch let error as URLError {
            return .err(code: -1, message: networkErrorMessage(error, request: request))
        } catch {
            return .err(code: -1, message: error.localizedDescription)
        }
    }

    private func networkErrorMessage(_ error: Error, request: URLRequest) -> String {
        let detail = (error as? URLError)?.localizedDescription ?? error.localizedDescription
        let refused = detail.lowercased().contains("refused")
            || (error as? URLError)?.code == .cannotConnectToHost
        let hint = refused
            ? " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)"
            : ""
        let method = request.httpMethod ?? "GET"
        let url = request.url?.absoluteString ?? ""
        return "\(detail) → \(method) \(url)\(hint)"
    }

    private func errorMessage(_ body: String, fallback: String) -> String {
        if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return fallback }
        if let err = try? SyncJson.decode(ErrorBody.self, from: body) {
            return err.message ?? err.error ?? body
        }
        let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? fallback : trimmed
    }

    private struct ErrorBody: Codable {
        var message: String? = nil
        var error: String? = nil

        init(from decoder: Decoder) throws {
            let c = try decoder.container(keyedBy: CodingKeys.self)
            message = try c.decodeIfPresent(String.self, forKey: .message)
            error = try c.decodeIfPresent(String.self, forKey: .error)
        }
    }

    private func ressourcesQueryParam(_ ressourcesQuery: String?) -> String {
        guard let ressourcesQuery, !ressourcesQuery.isEmpty else { return "" }
        return "&ressources=\(Self.encodeQuery(ressourcesQuery))"
    }

    private static func text(_ data: Data) -> String {
        String(data: data, encoding: .utf8) ?? ""
    }

    private static func fallbackMessage(_ response: HTTPURLResponse) -> String {
        HTTPURLResponse.localizedString(forStatusCode: response.statusCode)
    }

    /// `java.net.URLEncoder` charset: letters/digits and `-._*` unescaped.
    private static let urlEncoderAllowed: CharacterSet = {
        var set = CharacterSet.alphanumerics
        set.insert(charactersIn: "-._*")
        return set
    }()

    static func encodeQuery(_ value: String) -> String {
        value.addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? value
    }

    static func encodePath(_ segment: String) -> String {
        segment.split(separator: "/", omittingEmptySubsequences: false)
            .map { String($0).addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? String($0) }
            .joined(separator: "/")
    }
}