package com.taf.attendance.data import android.content.Context import android.content.SharedPreferences import com.google.android.gms.location.LocationServices import com.taf.attendance.model.AttendanceRecord import com.taf.attendance.data.ClockPayload import com.taf.attendance.data.PayslipDto import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.suspendCancellableCoroutine import android.util.Log import android.location.Location import kotlin.coroutines.resume import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import okhttp3.OkHttpClient import okhttp3.JavaNetCookieJar import okhttp3.logging.HttpLoggingInterceptor import java.net.CookieManager import java.net.CookiePolicy import com.taf.attendance.model.LeaveRequest import com.taf.attendance.model.LeaveBalance import com.taf.attendance.model.LeaveType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import java.time.LocalDateTime import java.time.format.DateTimeFormatter import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import android.util.Base64 import com.taf.attendance.model.Branch import com.taf.attendance.model.UserFace import java.io.File import kotlinx.coroutines.tasks.await import retrofit2.HttpException import java.util.UUID import androidx.work.* import com.taf.attendance.sync.SyncWorker import java.util.concurrent.TimeUnit data class ClockResult( val success: Boolean, val withinRange: Boolean, val distanceToNearest: Double? = null ) class AttendanceRepository(private val context: Context) { private val api: ApiService private val db = AppDatabase.get(context).attendanceDao() private val faceDao = AppDatabase.get(context).userFaceDao() private val branchDao = AppDatabase.get(context).branchDao() private val leaveDao = AppDatabase.get(context).leaveDao() private val locationClient = LocationServices.getFusedLocationProviderClient(context) private val prefs: SharedPreferences = context.getSharedPreferences("auth", Context.MODE_PRIVATE) private var authToken: String? = prefs.getString("token", null) private var userId: String = prefs.getString("user_id", "") ?: "" private val gson = com.google.gson.Gson() suspend fun getAuthorizedBranches(): List = withContext(Dispatchers.IO) { branchDao.getAll() } suspend fun currentLocation(): android.location.Location? { return try { locationClient.lastLocation.await() } catch (e: Exception) { null } } init { val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY // Log request + response body } val cookieManager = CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) } val client = OkHttpClient.Builder() .cookieJar(JavaNetCookieJar(cookieManager)) .addInterceptor { chain -> val reqBuilder = chain.request().newBuilder() val token = authToken ?: prefs.getString("token", null) if (!token.isNullOrEmpty()) { reqBuilder.addHeader("Authorization", "Bearer $token") } chain.proceed(reqBuilder.build()) } .addInterceptor(logging) .build() val retrofit = Retrofit.Builder() .baseUrl("https://bafta.cybexpte.com/api/") .client(client) // attach the client with logging .addConverterFactory(GsonConverterFactory.create()) .build() api = retrofit.create(ApiService::class.java) } suspend fun login(email: String, password: String): Boolean { return try { val resp = api.login(mapOf("email" to email, "password" to password)) authToken = resp.token userId = resp.user.id // Clear previous user's local data db.clear() saveCredentials(email, password, resp.token, resp.branches, resp.user.id) true } catch (e: Exception) { offlineLogin(email, password) } } suspend fun fetchRecent() = withContext(Dispatchers.IO) { try { // Fetch only last 7 days of records val records = api.recentClocks(days = 7) val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) db.clear() val mapped = records.map { val time = try { formatter.parse(it.clocked_at)?.time ?: 0L } catch (e: Exception) { 0L } AttendanceRecord( type = it.clock_type, time = time, lat = it.lat, lng = it.lng, synced = true ) } db.insertAll(mapped) } catch (e: Exception) { Log.e("FetchRecent", "Failed to fetch recent", e) } } suspend fun clock(type: String, photoPath: String?): ClockResult { val loc = try { locationClient.lastLocation.await() } catch (e: Exception) { null } val lat = loc?.latitude val lng = loc?.longitude // Check proximity to authorized branches var withinRange = true // Default to true for demo/no GPS scenarios var nearestDistance: Double? = null if (lat != null && lng != null) { val branches = getAuthorizedBranches() if (branches.isNotEmpty()) { // Check if any branches have GPS coordinates val branchesWithGPS = branches.filter { it.latitude != null && it.longitude != null } if (branchesWithGPS.isNotEmpty()) { // GPS validation is possible - calculate distances val distances = branchesWithGPS.map { branch -> calculateDistance(lat, lng, branch.latitude!!, branch.longitude!!) } nearestDistance = distances.minOrNull() withinRange = nearestDistance!! <= 50.0 } // If no branches have GPS coordinates, withinRange remains true (no validation) } // If no branches at all, withinRange remains true (demo mode) } val record = AttendanceRecord(type = type, time = System.currentTimeMillis(), lat = lat, lng = lng, photoPath = photoPath) db.insert(record) // Try immediate sync, but don't fail if it doesn't work try { sync() } catch (e: Exception) { Log.w("AttendanceRepository", "Immediate sync failed, will retry in background", e) // Schedule periodic sync to retry later triggerImmediateSync() } return ClockResult( success = true, withinRange = withinRange, distanceToNearest = nearestDistance ) } private fun calculateDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { val earthRadius = 6371000.0 // meters val dLat = Math.toRadians(lat2 - lat1) val dLng = Math.toRadians(lng2 - lng1) val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2) val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return earthRadius * c } suspend fun history(): List = db.getAll() suspend fun history(start: Long, end: Long): List = db.getRange(start, end) suspend fun lastEntrySince(since: Long): AttendanceRecord? = db.getLastSince(since) /** * Get the last clock entry from backend API for validation * Returns null if no entry found or if offline */ suspend fun getLastEntryFromBackend(): AttendanceRecord? = withContext(Dispatchers.IO) { try { val entry = api.getLastEntry() ?: return@withContext null val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val time = try { formatter.parse(entry.clocked_at)?.time ?: 0L } catch (e: Exception) { 0L } AttendanceRecord( type = entry.clock_type, time = time, lat = entry.lat, lng = entry.lng, synced = true ) } catch (e: Exception) { Log.e("GetLastEntry", "Failed to fetch last entry from backend", e) null } } suspend fun getUnsyncedCount(): Int = withContext(Dispatchers.IO) { db.getUnsyncedCount() } /** * Schedules periodic background sync for offline attendance records. * Runs every 15 minutes when there are unsynced records. */ fun schedulePeriodicSync() { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val syncWorkRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) .setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS ) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "attendance_sync", ExistingPeriodicWorkPolicy.KEEP, syncWorkRequest ) Log.d("AttendanceRepository", "Periodic sync scheduled") } /** * Triggers an immediate sync attempt if device is online and has unsynced records. */ fun triggerImmediateSync() { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val syncWorkRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) .build() WorkManager.getInstance(context).enqueueUniqueWork( "immediate_sync", ExistingWorkPolicy.REPLACE, syncWorkRequest ) Log.d("AttendanceRepository", "Immediate sync triggered") } /** * Cancels all scheduled sync work. */ fun cancelSync() { WorkManager.getInstance(context).cancelUniqueWork("attendance_sync") WorkManager.getInstance(context).cancelUniqueWork("immediate_sync") Log.d("AttendanceRepository", "Sync cancelled") } suspend fun myPayslips(): List = withContext(Dispatchers.IO) { try { api.myPayslips() } catch (e: Exception) { emptyList() } } suspend fun getPayslipHtml(id: String): String = withContext(Dispatchers.IO) { try { api.payslipHtml(id).string() } catch (e: Exception) { "" } } // Changed userId to String suspend fun saveUserFace(userId: String, path: String, embedding: String, synced: Boolean = false) { faceDao.upsert(UserFace(userId, path, embedding, synced)) } suspend fun getUserFace(userId: String): UserFace? = faceDao.getByUserId(userId) // Changed userId to String suspend fun downloadUserFace(userId: String) = withContext(Dispatchers.IO) { try { // This will require api.getFace to accept String userId val face = api.getFace(userId) val imgBytes = Base64.decode(face.image.substringAfter(','), Base64.DEFAULT) val dir = File(context.cacheDir, "images").apply { if (!exists()) mkdirs() } val file = File(dir, "registered_face.jpg") file.writeBytes(imgBytes) saveUserFace(userId, file.absolutePath, face.embedding, synced = true) // Mark as synced } catch (e: HttpException) { if (e.code() == 404) { return@withContext } else { Log.w("DownloadUserFace", "Failed to download face", e) } } catch (e: Exception) { Log.w("DownloadUserFace", "Failed to download face", e) } } // Changed userId to String suspend fun uploadUserFace(userId: String, path: String, embedding: String) = withContext(Dispatchers.IO) { try { val file = File(path) if (!file.exists()) return@withContext val reqFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) val part = MultipartBody.Part.createFormData("image", file.name, reqFile) val embBody = embedding.toRequestBody("text/plain".toMediaType()) // This will require api.uploadFace to accept String userId api.uploadFace(userId, part, embBody) } catch (e: Exception) { Log.e("UploadUserFace", "Failed to upload face", e) } } // Changed userId to String suspend fun reportFaceFailure(userId: String, refPath: String?, attemptPath: String) = withContext(Dispatchers.IO) { try { val attemptFile = File(attemptPath) if (!attemptFile.exists()) return@withContext val attemptBody = attemptFile.asRequestBody("image/jpeg".toMediaTypeOrNull()) val attemptPart = MultipartBody.Part.createFormData("image", attemptFile.name, attemptBody) val referencePart = if (refPath != null) { val refFile = File(refPath) if (refFile.exists()) { val refBody = refFile.asRequestBody("image/jpeg".toMediaTypeOrNull()) MultipartBody.Part.createFormData("reference", refFile.name, refBody) } else { val empty = ByteArray(0).toRequestBody("image/jpeg".toMediaType()) MultipartBody.Part.createFormData("reference", "", empty) } } else { val empty = ByteArray(0).toRequestBody("image/jpeg".toMediaType()) MultipartBody.Part.createFormData("reference", "", empty) } // userId is already String, .toString() is harmless val uidBody = userId.toRequestBody("text/plain".toMediaType()) // This will require api.reportFaceFailure's corresponding parameter to accept String api.reportFaceFailure(attemptPart, referencePart, uidBody) } catch (e: Exception) { Log.e("ReportFaceFailure", "Failed to report face failure", e) } } fun getUserId(): String = userId suspend fun sync() = withContext(Dispatchers.IO) { val unsynced = db.getUnsynced() val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) for (rec in unsynced) { try { val payload = ClockPayload( user_id = userId, // userId is already String here clock_type = rec.type, lat = rec.lat, lng = rec.lng, security_type = "password", // TODO: send actual security method clocked_at = formatter.format(Date(rec.time)) ) Log.d("ClockSync", "Posting record: $payload") api.logTime(payload) db.markSynced(rec.id) } catch (e: Exception) { Log.e("ClockSync", "Failed to sync record ${rec.id}: ${e.message}", e) // keep record for later } } } suspend fun logout() = withContext(Dispatchers.IO) { db.clear() // Clear local attendance records authToken = null // keep credentials in prefs for offline login } private suspend fun saveCredentials( email: String, password: String, token: String, branches: List?, userId: String // This is already String and correct ) { val hash = hashPassword(password + email) prefs.edit().apply { putString("token", token) putLong("token_time", System.currentTimeMillis()) putString("email", email) putString("pass_hash", hash) putString("user_id", userId) }.apply() // Save branches to database if (!branches.isNullOrEmpty()) { val branchEntities = branches.map { Branch(it.branch_id, it.name, it.latitude, it.longitude) } branchDao.upsert(branchEntities) } else { branchDao.deleteAll() } } private fun offlineLogin(email: String, password: String): Boolean { val storedEmail = prefs.getString("email", null) ?: return false if (storedEmail != email) return false val storedHash = prefs.getString("pass_hash", null) ?: return false val weekMs = 7 * 24 * 60 * 60 * 1000L val tokenTime = prefs.getLong("token_time", 0L) if (System.currentTimeMillis() - tokenTime > weekMs) return false return if (storedHash == hashPassword(password + email)) { authToken = prefs.getString("token", null) userId = prefs.getString("user_id", "") ?: "" authToken != null } else { false } } private fun hashPassword(input: String): String { val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) return bytes.joinToString("") { "%02x".format(it) } } // Leave Management Methods suspend fun getLeaveRequests(): List = withContext(Dispatchers.IO) { try { val response = api.getLeaveRequests() // Cache locally response.forEach { request -> leaveDao.insertLeaveRequest(request.toEntity()) } response } catch (e: Exception) { // Return cached data if offline leaveDao.getAllLeaveRequests().first().map { it.toLeaveRequest() } } } suspend fun getLeaveBalances(): List = withContext(Dispatchers.IO) { try { val response = api.getLeaveBalances() // Cache locally leaveDao.insertLeaveBalances(response.map { it.toEntity() }) response } catch (e: Exception) { // Return cached data if offline leaveDao.getLeaveBalances(userId).first().map { it.toLeaveBalance() } } } suspend fun getLeaveTypes(): List = withContext(Dispatchers.IO) { try { api.getLeaveTypes() } catch (e: Exception) { emptyList() } } suspend fun submitLeaveRequest(leaveType: String, startDate: String, endDate: String, reason: String): Boolean = withContext(Dispatchers.IO) { try { val request = LeaveRequest( id = 0, // will be assigned by backend employeeName = "", // will be filled by backend user_id = userId, org_id = null, leaveType = leaveType, startDate = startDate, endDate = endDate, reason = reason, status = "Pending", createdAt = "", // will be filled by backend approvedAt = null, approvedBy = null ) val payload = LeaveRequestPayload(data = request) api.submitLeaveRequest(payload) true } catch (e: Exception) { false } } }