import Foundation enum ChatServiceError: LocalizedError { case unexpectedStatus(String) case decoding(debugDescription: String) var errorDescription: String? { switch self { case .unexpectedStatus(let message): return message case .decoding(let debugDescription): return AppConfig.DEBUG ? debugDescription : NSLocalizedString("Не удалось обработать данные чатов.", comment: "") } } } final class ChatService { private let client: NetworkClient private let decoder: JSONDecoder init(client: NetworkClient = .shared) { self.client = client self.decoder = JSONDecoder() self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) } func fetchPrivateChats( offset: Int, limit: Int, completion: @escaping (Result) -> Void ) { let query: [String: String?] = [ "offset": String(offset), "limit": String(limit) ] client.request( path: "/v1/chat/private/list", method: .get, query: query, requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список чатов.", comment: "") completion(.failure(ChatServiceError.unexpectedStatus(message))) return } completion(.success(apiResponse.data)) } catch { let debugMessage = Self.describeDecodingError(error: error, data: response.data) if AppConfig.DEBUG { print("[ChatService] decode private chats failed: \(debugMessage)") } completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) } case .failure(let error): completion(.failure(error)) } } } func fetchPrivateChatHistory( chatId: String, beforeMessageId: String?, limit: Int, completion: @escaping (Result) -> Void ) { var query: [String: String?] = [ "chat_id": chatId, "limit": String(limit) ] if let beforeMessageId, !beforeMessageId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { query["before_message_id"] = beforeMessageId } client.request( path: "/v1/chat/private/history", method: .get, query: query, requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить историю чата.", comment: "") completion(.failure(ChatServiceError.unexpectedStatus(message))) return } completion(.success(apiResponse.data)) } catch { let debugMessage = Self.describeDecodingError(error: error, data: response.data) if AppConfig.DEBUG { print("[ChatService] decode private chat history failed: \(debugMessage)") } completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) } case .failure(let error): completion(.failure(error)) } } } func createOrFindPrivateChat( targetUserId: String, completion: @escaping (Result) -> Void ) { let query: [String: String?] = [ "target_user_id": targetUserId ] client.request( path: "/v1/chat/private/create", method: .post, query: query, requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось создать чат.", comment: "") completion(.failure(ChatServiceError.unexpectedStatus(message))) return } completion(.success(apiResponse.data)) } catch { let debugMessage = Self.describeDecodingError(error: error, data: response.data) if AppConfig.DEBUG { print("[ChatService] create private chat decode failed: \(debugMessage)") } completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) } case .failure(let error): completion(.failure(error)) } } } func sendPrivateMessage( chatId: String, content: String, completion: @escaping (Result) -> Void ) { let payload: [String: Any] = [ "chat_id": chatId, "content": content, "message_type": ["text"] ] guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { completion(.failure(ChatServiceError.decoding(debugDescription: "Невозможно сериализовать тело запроса."))) return } client.request( path: "/v1/chat/private/send", method: .post, headers: ["Content-Type": "application/json"], body: body, requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось отправить сообщение.", comment: "") completion(.failure(ChatServiceError.unexpectedStatus(message))) return } completion(.success(apiResponse.data)) } catch { let debugMessage = Self.describeDecodingError(error: error, data: response.data) if AppConfig.DEBUG { print("[ChatService] send private message decode failed: \(debugMessage)") } completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) } case .failure(let error): completion(.failure(error)) } } } private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) if let date = iso8601WithFractionalSeconds.date(from: string) { return date } if let date = iso8601Simple.date(from: string) { return date } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Невозможно декодировать дату: \(string)" ) } private static func describeDecodingError(error: Error, data: Data) -> String { var parts: [String] = [] if let decodingError = error as? DecodingError { parts.append(decodingDescription(from: decodingError)) } else { parts.append(error.localizedDescription) } if let payload = truncatedPayload(from: data) { parts.append("payload=\(payload)") } return parts.joined(separator: "\n") } private static func decodingDescription(from error: DecodingError) -> String { switch error { case .typeMismatch(let type, let context): return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)" case .valueNotFound(let type, let context): return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)" case .keyNotFound(let key, let context): return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)" case .dataCorrupted(let context): return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)" @unknown default: return error.localizedDescription } } private static func codingPath(from context: DecodingError.Context) -> String { let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty } return path.isEmpty ? "root" : path.joined(separator: ".") } private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? { guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else { return nil } if string.count <= limit { return string } let index = string.index(string.startIndex, offsetBy: limit) return String(string[string.startIndex..