package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.controller.dto.CardPrepareRequest import com.android.trisolarisserver.controller.dto.CardPrepareResponse import com.android.trisolarisserver.controller.dto.CardRevokeResponse import com.android.trisolarisserver.controller.dto.IssueCardRequest import com.android.trisolarisserver.controller.dto.IssuedCardResponse import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.room.IssuedCard import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.IssuedCardRepo import com.android.trisolarisserver.repo.PropertyCardCounterRepo import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException import java.time.OffsetDateTime import java.time.ZoneId import java.util.UUID @RestController @RequestMapping("/properties/{propertyId}/room-stays") class IssuedCards( private val propertyAccess: PropertyAccess, private val roomStayRepo: RoomStayRepo, private val issuedCardRepo: IssuedCardRepo, private val appUserRepo: AppUserRepo, private val counterRepo: PropertyCardCounterRepo, private val propertyRepo: PropertyRepo ) { @PostMapping("/{roomStayId}/cards/prepare") @ResponseStatus(HttpStatus.CREATED) @Transactional fun prepare( @PathVariable propertyId: UUID, @PathVariable roomStayId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: CardPrepareRequest ): CardPrepareResponse { val actor = requireIssueActor(propertyId, principal) val stay = roomStayRepo.findById(roomStayId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") } if (stay.property.id != propertyId) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") } if (stay.toAt != null) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed") } val issuedAt = nowForProperty(stay.property.timezone) val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required") if (!expiresAt.isAfter(issuedAt)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt") } val cardIndex = nextCardIndex(stay.property.id!!) val payload = buildSector0Payload(stay.room.roomNumber, cardIndex, issuedAt, expiresAt) return CardPrepareResponse( cardIndex = cardIndex, key = payload.key, timeData = payload.timeData, issuedAt = issuedAt.toString(), expiresAt = expiresAt.toString(), sector3Block0 = encodeBlock(actor.name), sector3Block1 = encodeBlock(actor.id?.toString()), sector3Block2 = encodeBlock(null) ) } @PostMapping("/{roomStayId}/cards") @ResponseStatus(HttpStatus.CREATED) @Transactional fun issue( @PathVariable propertyId: UUID, @PathVariable roomStayId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: IssueCardRequest ): IssuedCardResponse { val actor = requireIssueActor(propertyId, principal) if (request.cardId.isBlank()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardId required") } if (request.cardIndex <= 0) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required") } val stay = roomStayRepo.findById(roomStayId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") } if (stay.property.id != propertyId) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") } if (stay.toAt != null) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed") } val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone) val expiresAt = parseOffset(request.expiresAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required") if (!expiresAt.isAfter(issuedAt)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt") } val now = OffsetDateTime.now() if (issuedCardRepo.existsActiveForRoomStay(roomStayId, now)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room stay") } if (issuedCardRepo.existsActiveForRoom(propertyId, stay.room.id!!, now)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room") } val card = IssuedCard( property = stay.property, room = stay.room, roomStay = stay, cardId = request.cardId.trim(), cardIndex = request.cardIndex, issuedAt = issuedAt, expiresAt = expiresAt, issuedBy = actor ) return issuedCardRepo.save(card).toResponse() } @GetMapping("/{roomStayId}/cards") fun list( @PathVariable propertyId: UUID, @PathVariable roomStayId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): List { requireViewActor(propertyId, principal) val stay = roomStayRepo.findById(roomStayId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") } if (stay.property.id != propertyId) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") } return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId) .map { it.toResponse() } } @PostMapping("/cards/{cardIndex}/revoke") @org.springframework.transaction.annotation.Transactional fun revoke( @PathVariable propertyId: UUID, @PathVariable cardIndex: Int, @AuthenticationPrincipal principal: MyPrincipal? ): CardRevokeResponse { val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found") requireRevokeActor(propertyId, principal, card.roomStay == null) if (card.revokedAt == null) { val now = nowForProperty(card.property.timezone) card.revokedAt = now card.expiresAt = now issuedCardRepo.save(card) } val key = buildSector0Block2(card.room.roomNumber, card.cardIndex) val timeData = buildSector0TimeData(card.issuedAt, card.expiresAt, key) return CardRevokeResponse(timeData = timeData) } @GetMapping("/cards/{cardIndex}") fun getCardByIndex( @PathVariable propertyId: UUID, @PathVariable cardIndex: Int, @AuthenticationPrincipal principal: MyPrincipal? ): IssuedCardResponse { requireCardAdminActor(propertyId, principal) val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found") return card.toResponse() } private fun parseOffset(value: String?): OffsetDateTime? { if (value.isNullOrBlank()) return null return try { OffsetDateTime.parse(value.trim()) } catch (_: Exception) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp") } } private fun nowForProperty(timezone: String?): OffsetDateTime { val zone = try { if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone) } catch (_: Exception) { ZoneId.of("Asia/Kolkata") } return OffsetDateTime.now(zone) } private fun encodeBlock(value: String?): String { val raw = (value ?: "").padEnd(16).take(16) val bytes = raw.toByteArray(Charsets.UTF_8) val sb = StringBuilder(bytes.size * 2) for (b in bytes) { sb.append(String.format("%02X", b)) } return sb.toString() } private fun requireMember(propertyId: UUID, principal: MyPrincipal?) { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } propertyAccess.requireMember(propertyId, principal.userId) } private fun requireViewActor(propertyId: UUID, principal: MyPrincipal?) { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } propertyAccess.requireAnyRole( propertyId, principal.userId, Role.STAFF, Role.ADMIN, Role.MANAGER, Role.SUPERVISOR ) } private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } propertyAccess.requireMember(propertyId, principal.userId) propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) return appUserRepo.findById(principal.userId).orElseThrow { ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") } } private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?, isTempCard: Boolean) { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } propertyAccess.requireMember(propertyId, principal.userId) if (isTempCard) { propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) } else { propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN) } } private fun requireCardAdminActor(propertyId: UUID, principal: MyPrincipal?) { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } propertyAccess.requireMember(propertyId, principal.userId) propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) } private fun nextCardIndex(propertyId: UUID): Int { var counter = counterRepo.findByPropertyIdForUpdate(propertyId) if (counter == null) { val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter( property = property, nextIndex = 10001 )) counter = counterRepo.findByPropertyIdForUpdate(propertyId) } val current = counter!!.nextIndex counter.nextIndex = current + 1 counter.updatedAt = OffsetDateTime.now() counterRepo.save(counter) return current } private fun buildSector0Payload( roomNumber: Int, cardIndex: Int, issuedAt: OffsetDateTime, expiresAt: OffsetDateTime ): Sector0Payload { val key = buildSector0Block2(roomNumber, cardIndex) val finalData = buildSector0TimeData(issuedAt, expiresAt, key) return Sector0Payload(key, finalData) } private fun buildSector0TimeData( issuedAt: OffsetDateTime, expiresAt: OffsetDateTime, key: String? = null ): String { val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt) val checkSum = calculateChecksum((key ?: "") + newData) return newData + checkSum } private fun buildSector0Block2(roomNumber: Int, cardID: Int): String { val guestID = cardID + 1 val cardIdStr = cardID.toString().padStart(6, '0') val guestIdStr = guestID.toString().padStart(6, '0') val finalRoom = roomNumber.toString().padStart(2, '0') return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000" } private fun formatDateComponents(time: OffsetDateTime): String { val minute = time.minute.toString().padStart(2, '0') val hour = time.hour.toString().padStart(2, '0') val day = time.dayOfMonth.toString().padStart(2, '0') val month = time.monthValue.toString().padStart(2, '0') val year = time.year.toString().takeLast(2) return "${minute}${hour}${day}${month}${year}" } private fun calculateChecksum(dataHex: String): String { val data = hexStringToByteArray(dataHex) var checksum = 0 for (byte in data) { checksum = calculateByteChecksum(byte, checksum) } return String.format("%02X", checksum) } private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int { var checksum = currentChecksum var b = byte.toInt() for (i in 0 until 8) { checksum = if ((checksum xor b) and 1 != 0) { (checksum xor 0x18) shr 1 or 0x80 } else { checksum shr 1 } b = b shr 1 } return checksum } private fun hexStringToByteArray(hexString: String): ByteArray { val len = hexString.length val data = ByteArray(len / 2) for (i in 0 until len step 2) { data[i / 2] = ((Character.digit(hexString[i], 16) shl 4) + Character.digit(hexString[i + 1], 16)).toByte() } return data } } private data class Sector0Payload( val key: String, val timeData: String ) private fun IssuedCard.toResponse(): IssuedCardResponse { return IssuedCardResponse( id = id!!, propertyId = property.id!!, roomId = room.id!!, roomStayId = roomStay?.id, cardId = cardId, cardIndex = cardIndex, issuedAt = issuedAt.toString(), expiresAt = expiresAt.toString(), issuedByUserId = issuedBy?.id, revokedAt = revokedAt?.toString() ) }