package de.oaa.xxx.games.chastity.cardlock;

import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.Principal;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

import javax.imageio.ImageIO;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import de.oaa.xxx.games.chastity.common.CodeCreator;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationEntity;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteEntity;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.lockcontroll.LockControllType;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationEntity;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationRepository;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryRepository;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;

@RestController
@RequestMapping("/keyholder")
public class CardLockController {

	private final CardlockRepository cardlockRepository;
	private final UserRepository userRepository;
	private final KeyholderInvitationRepository invitationRepository;
	private final CommunityVerificationRepository verificationRepository;
	private final CommunityVerificationVoteRepository verificationVoteRepository;
	private final KeyholderNotificationRepository keyholderNotificationRepository;
	private final LockeeInvitationRepository lockeeInvitationRepository;
	private final AssignedTaskRepository assignedTaskRepository;
	private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
	private final CommunityTaskVoteRepository communityTaskVoteRepository;
	private final UnlockCodeHistoryRepository unlockCodeHistoryRepository;
	private final UnlockCodeHistoryService unlockCodeHistoryService;
	private final SystemMessageService systemMessageService;
	private final CardLockServiceFactory cardLockServiceFactory;
	private final SubscriptionLimitService subscriptionLimitService;
	private final UserService userService;

	@Value("${app.base-url:http://localhost:8080}")
	private String baseUrl;

	public CardLockController(CardlockRepository cardlockRepository, 
			UserRepository userRepository,
			KeyholderInvitationRepository invitationRepository, 
			CommunityVerificationRepository verificationRepository,
			CommunityVerificationVoteRepository verificationVoteRepository,
			KeyholderNotificationRepository keyholderNotificationRepository,
			LockeeInvitationRepository lockeeInvitationRepository, 
			AssignedTaskRepository assignedTaskRepository,
			KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
			CommunityTaskVoteRepository communityTaskVoteRepository,
			UnlockCodeHistoryRepository unlockCodeHistoryRepository,
			UnlockCodeHistoryService unlockCodeHistoryService,
			SystemMessageService systemMessageService,
			CardLockServiceFactory cardLockServiceFactory,
			LockControlFactory lockControlFactory,
			SubscriptionLimitService subscriptionLimitService,
			UserService userService) {
		this.cardlockRepository = cardlockRepository;
		this.userRepository = userRepository;
		this.invitationRepository = invitationRepository;
		this.verificationRepository = verificationRepository;
		this.verificationVoteRepository = verificationVoteRepository;
		this.keyholderNotificationRepository = keyholderNotificationRepository;
		this.lockeeInvitationRepository = lockeeInvitationRepository;
		this.assignedTaskRepository = assignedTaskRepository;
		this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
		this.communityTaskVoteRepository = communityTaskVoteRepository;
		this.unlockCodeHistoryRepository = unlockCodeHistoryRepository;
		this.unlockCodeHistoryService = unlockCodeHistoryService;
		this.systemMessageService = systemMessageService;
		this.cardLockServiceFactory = cardLockServiceFactory;
		this.subscriptionLimitService = subscriptionLimitService;
		this.userService = userService;
	}

	record CreateCardLockRequest(String name, UUID keyholder, UUID lockeeUserId, boolean lockeeDetailsVisible,
			List<CardEnum> initialCards, Integer pickEveryMinute, boolean accumulatePicks, boolean showRemainingCards,
			LocalDateTime latestOpeningtime, Integer hygineOpeningDurationMinutes, Integer hygineOpeningEveryMinites,
			List<Task> tasks, boolean requiresVerification, boolean testLock, Integer unlockCodeLines,
			TaskMode taskMode, LockControllType controllType) {
	}

	private static final SecureRandom RNG = new SecureRandom();

	private String generateUnlockCode(int lines) {
		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < lines; i++) {
			sb.append(RNG.nextInt(10));
		}
		return sb.toString();
	}

	@PostMapping("/cardlock")
	public ResponseEntity<Map<String, Object>> createCardLock(@RequestBody CreateCardLockRequest req,
			Principal principal) {

		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		if (req.initialCards() == null || req.initialCards().isEmpty() || req.pickEveryMinute() == null
				|| req.pickEveryMinute() < 1)
			return ResponseEntity.badRequest().build();

		// Friend-lockee path: current user becomes keyholder, invite lockee
		boolean friendLockee = req.lockeeUserId() != null && !req.lockeeUserId().equals(myId);
		if (friendLockee) {
			var lockeeOpt = userRepository.findById(req.lockeeUserId());
			if (lockeeOpt.isEmpty())
				return ResponseEntity.badRequest().build();
			var lockee = lockeeOpt.get();

			if (cardLockServiceFactory.hasActiveLock(req.lockeeUserId()))
				return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));

			LocalDateTime now = LocalDateTime.now();
			CardLockEntity lock = new CardLockEntity();
			lock.setName(req.name());
			lock.setLockee(lockee.getUserId());
			lock.setKeyholder(myId);
			lock.setInitialCards(req.initialCards());
			lock.setPickEveryMinute(req.pickEveryMinute());
			lock.setAccumulatePicks(req.accumulatePicks());
			lock.setShowRemainingCards(req.showRemainingCards());
			lock.setLatestOpeningtime(req.latestOpeningtime());
			lock.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
			lock.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
			lock.setTasks(req.tasks() != null ? req.tasks() : List.of());
			lock.setRequiresVerification(req.requiresVerification());
			lock.setTestLock(false);
			lock.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM);
			// startTime, unlockCode, unlockCodeLines left null until lockee accepts
			cardlockRepository.save(lock);

			String token = UUID.randomUUID().toString().replace("-", "");
			LockeeInvitationEntity inv = new LockeeInvitationEntity();
			inv.setLockId(lock.getLockId());
			inv.setLockeeUserId(lockee.getUserId());
			inv.setKeyholderUserId(myId);
			inv.setToken(token);
			inv.setCreatedAt(now);
			inv.setDetailsVisible(req.lockeeDetailsVisible());
			lockeeInvitationRepository.save(inv);

			systemMessageService.pushInvitationUpdate(lockee.getUserId());

			return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "lockeeInvitationSent", true));
		}

		// Self-lockee path (existing behavior)
		if (cardLockServiceFactory.hasActiveLock(myId))
			return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));

		LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
		if (controllType == LockControllType.TTLOCK && !subscriptionLimitService.hasActivePaidSubscription(myId)) {
			return ResponseEntity.status(403).body(Map.of("error", "subscription_required"));
		}

		int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1) ? req.unlockCodeLines() : 5;

		CardLockEntity lock = new CardLockEntity();
		lock.setName(req.name());
		lock.setLockee(myId);
		lock.setKeyholder(null); // set only after invitation is confirmed
		lock.setInitialCards(req.initialCards());
		lock.setPickEveryMinute(req.pickEveryMinute());
		lock.setAccumulatePicks(req.accumulatePicks());
		lock.setShowRemainingCards(req.showRemainingCards());
		lock.setLatestOpeningtime(req.latestOpeningtime());
		lock.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
		lock.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
		lock.setTasks(req.tasks() != null ? req.tasks() : List.of());
		lock.setRequiresVerification(req.requiresVerification());
		lock.setTestLock(req.testLock());
		lock.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM);
		lock.setUnlockCodeLength(codeLines);
		lock.setControllType(controllType);

		LocalDateTime now = LocalDateTime.now();
		lock.setStartTime(now);
		lock.setAvailableCards(new ArrayList<>(req.initialCards()));
		lock.setOpenPicks(0);
		lock.setNextCardIn(now.plusMinutes(req.pickEveryMinute()));
		if (req.hygineOpeningEveryMinites() != null) {
			lock.setLastHygineOpening(now);
		}
		cardlockRepository.save(lock); // erst speichern, damit Lock-ID vorhanden ist

		// Initialen Unlock-Code / TTLock-PIN via LockControl setzen
		CardLockService initService = cardLockServiceFactory.create(lock);
		if (initService.getLockControl() != null) {
			initService.getLockControl().lock();
		} else {
			// Fallback: direkte Code-Generierung (UNLOCK_CODE ohne Factory)
			lock.setUnlockCode(generateUnlockCode(codeLines));
			cardlockRepository.save(lock);
		}

		boolean keyholderPending = false;
		if (req.keyholder() != null) {
			var khOpt = userRepository.findById(req.keyholder());
			if (khOpt.isPresent()) {
				var kh = khOpt.get();
				String token = UUID.randomUUID().toString().replace("-", "");

				KeyholderInvitationEntity inv = new KeyholderInvitationEntity();
				inv.setLockId(lock.getLockId());
				inv.setKeyholderUserId(kh.getUserId());
				inv.setLockeeUserId(myId);
				inv.setToken(token);
				inv.setCreatedAt(now);
				invitationRepository.save(inv);

				systemMessageService.pushInvitationUpdate(kh.getUserId());

				keyholderPending = true;
			}
		}

		return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "unlockCode", lock.getUnlockCode(),
				"keyholderPending", keyholderPending));
	}

	@PostMapping("/cardlock/{lockId}/draw")
	@Transactional
	public ResponseEntity<Map<String, Object>> drawCard(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		CardLockService service = cardLockServiceFactory.create(l);
		CardDTO dto = service.getNextCard();
		if (dto == null)
			return ResponseEntity.status(409).body(Map.of("error", "Keine Karte verfügbar"));

		String taskPending = (dto.card() == CardEnum.TASK) ? service.getPendingTaskMode() : null;

		Map<String, Object> result = new HashMap<>();
		result.put("card", dto.card().name());
		result.put("unlockCode", dto.unlockCode() != null ? dto.unlockCode() : "");
		if (taskPending != null)
			result.put("taskPending", taskPending);

		// Grüne Karte → Entsperrcode-Historie speichern + Keyholder benachrichtigen
		if (dto.unlockCode() != null && !dto.unlockCode().isBlank()) {
			unlockCodeHistoryService.save(myId, l.getLockId(), l.getName(), dto.unlockCode(), "GREEN_CARD");
			if (l.getKeyholder() != null) {
				String meName = userRepository.findById(myId).map(u -> u.getName()).orElse("");
				sendMessage(myId, l.getKeyholder(),
						meName + " hat die grüne Karte gezogen! Der Entsperrcode wurde angezeigt.",
						"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
			}
		}

		return ResponseEntity.ok(result);
	}

	@PostMapping("/cardlock/{lockId}/hygiene/start")
	@Transactional
	public ResponseEntity<Map<String, Object>> startHygieneOpening(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		cardLockServiceFactory.create(l).startHygieneOpening();
		return ResponseEntity.ok(Map.of("unlockCode", l.getUnlockCode(), "durationMinutes",
				l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30));
	}

	@PostMapping("/cardlock/{lockId}/hygiene/end")
	@Transactional
	public ResponseEntity<Map<String, Object>> endHygieneOpening(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		String code = cardLockServiceFactory.create(l).endTempOpening();
		return ResponseEntity.ok(Map.of("newUnlockCode", code));
	}

	@PostMapping("/cardlock/{lockId}/task/complete")
	@Transactional
	public ResponseEntity<Void> completeTask(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		CardLockService service = cardLockServiceFactory.create(l);
		service.clearTask();
		return ResponseEntity.noContent().build();
	}

	@PostMapping("/cardlock/{lockId}/relock")
	@Transactional
	public ResponseEntity<Void> relock(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
		if (l.getControllType() != LockControllType.TTLOCK) return ResponseEntity.status(409).build();

		var lc = cardLockServiceFactory.create(l).getLockControl();
		if (lc != null) lc.lock();
		return ResponseEntity.noContent().build();
	}

	@PostMapping("/cardlock/{lockId}/green/keep")
	@Transactional
	public ResponseEntity<Void> greenKeep(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		CardLockService service = cardLockServiceFactory.create(l);
		service.putBackGreen();

		// Grüne Karte zurückgelegt → Keyholder benachrichtigen
		if (l.getKeyholder() != null) {
			String meName = userRepository.findById(myId).map(u -> u.getName()).orElse("");
			sendMessage(myId, l.getKeyholder(),
					meName + " hat die grüne Karte zurückgelegt und bleibt im Lock.", "/games/chastity/keyholder.html",
					de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
		}

		return ResponseEntity.noContent().build();
	}

	@GetMapping("/mylock")
	public ResponseEntity<Map<String, Object>> getMyActiveLock(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();
		var activeLockId = cardLockServiceFactory.findActiveLockId(myId);
		if (activeLockId.isEmpty())
			return ResponseEntity.noContent().build();
		return ResponseEntity.ok(Map.of("lockId", activeLockId.get().toString()));
	}

	@GetMapping("/cardlock/{lockId}")
	public ResponseEntity<Map<String, Object>> getLock(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		Map<String, Long> cardCounts = new LinkedHashMap<>();
		if (l.getAvailableCards() != null) {
			l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum));
		}
		long totalCards = l.getAvailableCards() != null ? l.getAvailableCards().size() : 0;

		// Hygiene-Berechnung
		boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null;
		boolean hygieneOpeningDue = false;
		long hygieneSecondsRemaining = 0;
		if (hygieneEnabled) {
			LocalDateTime base = l.getLastHygineOpening() != null ? l.getLastHygineOpening() : l.getStartTime();
			if (base != null) {
				LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites());
				hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene);
				hygieneOpeningDue = hygieneSecondsRemaining <= 0;
			}
		}

		Map<String, Object> result = new HashMap<>();
		result.put("lockId", l.getLockId().toString());
		result.put("name", l.getName() != null ? l.getName() : "");
		result.put("showRemainingCards", l.isShowRemainingCards());
		result.put("availableCardCounts", cardCounts);
		result.put("totalCards", totalCards);
		result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0);
		result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : "");
		result.put("frozenUntill", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null);
		result.put("currentTask", l.getCurrentTask() != null ? l.getCurrentTask() : null);
		result.put("currentTaskDescription", l.getCurrentTaskDescription());
		result.put("taskFrozenUntil", l.getTaskUntil() != null ? l.getTaskUntil().toString() : null);
		result.put("hygieneEnabled", hygieneEnabled);
		result.put("hygieneOpeningDue", hygieneOpeningDue);
		result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
		result.put("hygieneOpeningActive", l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE == l.getTempOpeningReason());
		boolean tempOpeningActive = l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE != l.getTempOpeningReason();
		result.put("tempOpeningActive", tempOpeningActive);
		if (tempOpeningActive) {
			result.put("tempOpeningUnlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
		}
		result.put("hygieneOpeningStarted",
				l.getTempOpeningTime() != null ? l.getTempOpeningTime().toString() : null);
		result.put("hygieneDurationMinutes",
				l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 0);
		result.put("hasKeyholder", l.getKeyholder() != null);
		result.put("keyholderInvitationPending",
				l.getKeyholder() == null && !invitationRepository.findByLockId(l.getLockId()).isEmpty());
		if (l.getKeyholder() != null) {
			userRepository.findById(l.getKeyholder()).ifPresent(kh -> {
				result.put("keyholderName", kh.getName());
				result.put("keyholderUserId", kh.getUserId().toString());
				result.put("keyholderProfilePic", kh.getProfilePicture());
			});
		}

		// Verifikation
		boolean verificationDue = false;
		String verificationTodayId = null;
		String verificationPendingId = null;
		String verificationPendingCode = null;
		long verificationUpvotes = 0;
		long verificationDownvotes = 0;
		if (l.isRequiresVerification()) {
			LocalDateTime todayStart = LocalDate.now().atStartOfDay();
			LocalDateTime todayEnd = todayStart.plusDays(1);
			var completed = verificationRepository
					.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(l.getLockId(), todayStart, todayEnd);
			if (!completed.isEmpty()) {
				var todayV = completed.get(0);
				verificationTodayId = todayV.getDisplayId().toString();
				var votes = verificationVoteRepository.findAllByVerificationId(todayV.getDisplayId());
				verificationUpvotes = votes.stream().filter(CommunityVerificationVoteEntity::isUpvote).count();
				verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count();
			} else {
				verificationDue = true;
				var pending = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNull(l.getLockId(),
						todayStart, todayEnd);
				if (!pending.isEmpty()) {
					verificationPendingId = pending.get(0).getDisplayId().toString();
					verificationPendingCode = pending.get(0).getCode();
				}
			}
		}
		result.put("verificationRequired", l.isRequiresVerification());
		result.put("verificationDue", verificationDue);
		result.put("verificationTodayId", verificationTodayId);
		result.put("verificationUpvotes", verificationUpvotes);
		result.put("verificationDownvotes", verificationDownvotes);
		result.put("verificationPendingId", verificationPendingId);
		result.put("verificationPendingCode", verificationPendingCode);

		// Abgelaufene Aufgaben prüfen und Strafe anwenden
		var expiredTasks = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING").stream()
				.filter(t -> t.getAcceptDeadline().isBefore(LocalDateTime.now())).toList();
		if (!expiredTasks.isEmpty()) {
			CardLockService penaltyService = cardLockServiceFactory.create(l);
			for (var t : expiredTasks) {
				t.setStatus("EXPIRED");
				penaltyService.applyAssignedTaskPenalty(t);
				assignedTaskRepository.save(t);
				sendMessage(l.getKeyholder(), l.getLockee(),
						"Die dir gestellte Aufgabe ist abgelaufen, ohne dass du reagiert hast. Die Strafe wurde automatisch angewendet.",
						"/games/chastity/activelock.html?lockId=" + l.getLockId(), de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
			}
		}

		// Ausstehende Keyholder-Aufgaben (ohne Aufgabentext)
		var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING").stream()
				.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).map(t -> {
					Map<String, Object> m = new LinkedHashMap<>();
					m.put("taskId", t.getTaskId().toString());
					m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText());
					m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : "");
					m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0);
					m.put("assignedAt", t.getAssignedAt().toString());
					m.put("acceptDeadline", t.getAcceptDeadline().toString());
					m.put("penaltyFreezeMinutes",
							t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0);
					m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0);
					return m;
				}).toList();
		result.put("assignedTasks", pendingAssigned);
		result.put("taskMode", l.getTaskMode());

		// Ausstehende Keyholder-Choices
		boolean pendingKeyholderChoice = !keyholderTaskChoiceRepository.findByLockIdAndActiveTrue(l.getLockId())
				.isEmpty();
		result.put("pendingKeyholderChoice", pendingKeyholderChoice);

		// Aktive Community-Vote
		var activeVotes = communityTaskVoteRepository.findByActiveTrue().stream()
				.filter(v -> v.getLockId().equals(l.getLockId())).findFirst();
		if (activeVotes.isPresent()) {
			var v = activeVotes.get();
			result.put("activeCommunityVote",
					Map.of("voteSessionId", v.getDisplayId().toString(), "expiresAt", v.getExpiresAt().toString()));
		}

		// Notfall-Entsperrung: nach 1 Stunde automatisch öffnen
		if (l.getEmergencyUnlockRequestedAt() != null && !l.isKeyholderRequestedUnlock()
				&& l.getEmergencyUnlockRequestedAt().isBefore(LocalDateTime.now().minusHours(1))) {
			l.setEmergencyAutoUnlocked(true);
			l.setKeyholderRequestedUnlock(true);
			cardlockRepository.save(l);
		}

		// Keyholder hat Unlock angefordert → Unlock-Code mitliefern
		result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock());
		if (l.isKeyholderRequestedUnlock()) {
			result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
			// Notfall-Freigaben werden nicht in der Historie gespeichert
			if (l.getEmergencyUnlockRequestedAt() == null) {
				unlockCodeHistoryService.save(myId, l.getLockId(), l.getName(), l.getUnlockCode(), "KEYHOLDER_UNLOCK");
			}
		}

		result.put("testLock", l.isTestLock());
		result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
		result.put("controllType", l.getControllType() != null ? l.getControllType().name() : "UNLOCK_CODE");
		if (l.isTestLock()) {
			result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
		}

		return ResponseEntity.ok(result);
	}

	@PostMapping("/cardlock/{lockId}/verification/start")
	@Transactional
	public ResponseEntity<Map<String, Object>> startVerification(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		LocalDateTime todayStart = LocalDate.now().atStartOfDay();
		LocalDateTime todayEnd = todayStart.plusDays(1);

		// Existierende Verifikation für heute zurückgeben statt neue anlegen
		var existing = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNull(lockId, todayStart,
				todayEnd);
		if (!existing.isEmpty()) {
			var ev = existing.get(0);
			return ResponseEntity.ok(Map.of("verificationId", ev.getDisplayId().toString(), "code", ev.getCode()));
		}
		var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId,
				todayStart, todayEnd);
		if (!completed.isEmpty()) {
			var cv = completed.get(0);
			return ResponseEntity.ok(Map.of("verificationId", cv.getDisplayId().toString(), "code", cv.getCode()));
		}

		CommunityVerificationEntity v = new CommunityVerificationEntity();
		v.setDisplayId(UUID.randomUUID());
		v.setLockId(lockId);
		v.setLockeeId(myId);
		v.setCode(CodeCreator.createAlphanumeric(6));
		v.setCreatedAt(LocalDateTime.now());
		if (l.getKeyholder() != null)
			v.setKeyholderId(l.getKeyholder());
		verificationRepository.save(v);

		return ResponseEntity.ok(Map.of("verificationId", v.getDisplayId().toString(), "code", v.getCode()));
	}

	@PostMapping(value = "/cardlock/{lockId}/verification/{verificationId}/complete", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	@Transactional
	public ResponseEntity<Void> completeVerification(@PathVariable("lockId") UUID lockId, @PathVariable("verificationId") UUID verificationId,
			@RequestParam MultipartFile image, Principal principal) throws IOException {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		if (!lockOpt.get().getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		var vOpt = verificationRepository.findById(verificationId);
		if (vOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var v = vOpt.get();
		if (!v.getLockId().equals(lockId))
			return ResponseEntity.status(403).build();

		v.setImage(scaleImage(image.getBytes(), 1024));
		verificationRepository.save(v);

		var lock = lockOpt.get();
		if (lock.getKeyholder() != null) {
			String meName = userRepository.findById(myId).map(u -> u.getName()).orElse("");
			sendMessage(myId, lock.getKeyholder(), "📸 " + meName + " hat eine Verifikation eingereicht.",
					"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
		}

		return ResponseEntity.noContent().build();
	}

	private byte[] scaleImage(byte[] input, int maxSize) throws IOException {
		BufferedImage original = ImageIO.read(new ByteArrayInputStream(input));
		if (original == null)
			return input;
		int w = original.getWidth();
		int h = original.getHeight();
		if (w <= maxSize && h <= maxSize)
			return input;
		double scale = (double) maxSize / Math.max(w, h);
		int newW = (int) (w * scale);
		int newH = (int) (h * scale);
		BufferedImage scaled = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB);
		Graphics2D g = scaled.createGraphics();
		g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
		g.drawImage(original, 0, 0, newW, newH, null);
		g.dispose();
		String format = "jpeg";
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		ImageIO.write(scaled, format, out);
		return out.toByteArray();
	}

	@DeleteMapping("/cardlock/{lockId}/verification/today")
	@Transactional
	public ResponseEntity<Void> renewVerification(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		if (!lockOpt.get().getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		LocalDateTime todayStart = LocalDate.now().atStartOfDay();
		LocalDateTime todayEnd = todayStart.plusDays(1);
		var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId,
				todayStart, todayEnd);
		for (var v : completed) {
			verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId());
			verificationRepository.delete(v);
		}
		return ResponseEntity.noContent().build();
	}

	// ── Keyholder-Dashboard Endpunkte ──

	@GetMapping("/invitations/mine/count")
	public ResponseEntity<Integer> countMyInvitations(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();
		int count = 0;
		for (var inv : invitationRepository.findByKeyholderUserId(myId)) {
			var lockOpt = cardlockRepository.findById(inv.getLockId());
			if (lockOpt.isEmpty()) continue;
			if (lockOpt.get().getKeyholder() != null) continue;
			count++;
		}
		return ResponseEntity.ok(count);
	}

	@GetMapping("/invitations/mine")
	public ResponseEntity<List<Map<String, Object>>> getMyInvitations(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var invitations = invitationRepository.findByKeyholderUserId(myId);
		List<Map<String, Object>> result = new ArrayList<>();
		for (var inv : invitations) {
			var lockOpt = cardlockRepository.findById(inv.getLockId());
			if (lockOpt.isEmpty())
				continue;
			var lock = lockOpt.get();
			if (lock.getKeyholder() != null)
				continue; // bereits akzeptiert
			var lockeeOpt = userRepository.findById(lock.getLockee());
			if (lockeeOpt.isEmpty())
				continue;
			var lockee = lockeeOpt.get();
			Map<String, Object> item = new HashMap<>();
			item.put("lockId", inv.getLockId().toString());
			item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
			item.put("lockeeName", lockee.getName());
			item.put("lockeeId", lockee.getUserId().toString());
			item.put("lockeeProfilePic", lockee.getProfilePicture());
			item.put("token", inv.getToken());
			item.put("createdAt", inv.getCreatedAt().toString());
			result.add(item);
		}
		return ResponseEntity.ok(result);
	}

	@Transactional
	@DeleteMapping("/invitations/mine/{token}")
	public ResponseEntity<Void> declineInvitation(@PathVariable String token, Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var invOpt = invitationRepository.findByToken(token);
		if (invOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var inv = invOpt.get();
		if (!inv.getKeyholderUserId().equals(myId))
			return ResponseEntity.status(403).build();

		var lockOpt = cardlockRepository.findById(inv.getLockId());
		invitationRepository.delete(inv);

		if (lockOpt.isPresent()) {
			var lock = lockOpt.get();
			systemMessageService.pushInvitationUpdate(lock.getLockee());
		}

		return ResponseEntity.noContent().build();
	}

	@GetMapping("/invitations/sent")
	public ResponseEntity<List<Map<String, Object>>> getSentKeyholderInvitations(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var invitations = invitationRepository.findByLockeeUserId(myId);
		List<Map<String, Object>> result = new ArrayList<>();
		for (var inv : invitations) {
			var lockOpt = cardlockRepository.findById(inv.getLockId());
			if (lockOpt.isEmpty())
				continue;
			var lock = lockOpt.get();
			if (lock.getKeyholder() != null)
				continue; // already accepted
			var khOpt = userRepository.findById(inv.getKeyholderUserId());
			if (khOpt.isEmpty())
				continue;
			var kh = khOpt.get();
			Map<String, Object> item = new HashMap<>();
			item.put("lockId", lock.getLockId().toString());
			item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
			item.put("keyholderName", kh.getName());
			item.put("keyholderProfilePic", kh.getProfilePicture());
			item.put("token", inv.getToken());
			item.put("createdAt", inv.getCreatedAt().toString());
			result.add(item);
		}
		return ResponseEntity.ok(result);
	}

	@Transactional
	@DeleteMapping("/invitations/sent/{token}")
	public ResponseEntity<Void> cancelSentKeyholderInvitation(@PathVariable String token, Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var invOpt = invitationRepository.findByToken(token);
		if (invOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var inv = invOpt.get();

		// Verify the lock belongs to the current user as lockee
		var lockOpt = cardlockRepository.findById(inv.getLockId());
		if (lockOpt.isEmpty() || !lockOpt.get().getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		invitationRepository.delete(inv);

		systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId());

		return ResponseEntity.noContent().build();
	}

	@GetMapping("/as-keyholder")
	public ResponseEntity<List<Map<String, Object>>> getLocksAsKeyholder(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(myId);
		List<Map<String, Object>> result = new ArrayList<>();
		for (var lock : locks) {
			var lockeeOpt = userRepository.findById(lock.getLockee());
			if (lockeeOpt.isEmpty())
				continue;
			var lockee = lockeeOpt.get();
			Map<String, Object> item = new HashMap<>();
			item.put("lockId", lock.getLockId().toString());
			item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
			item.put("lockeeName", lockee.getName());
			item.put("lockeeId", lockee.getUserId().toString());
			item.put("lockeeProfilePic", lockee.getProfilePicture());
			item.put("totalCards", lock.getAvailableCards() != null ? lock.getAvailableCards().size() : 0);
			item.put("startTime", lock.getStartTime() != null ? lock.getStartTime().toString() : null);
			boolean frozenByKh = lock.getFrozenUntil() != null && lock.getFrozenUntil().isAfter(LocalDateTime.now())
					&& (lock.getCurrentTask() == null || lock.getCurrentTask().isBlank());
			item.put("isFrozenByKeyholder", frozenByKh);
			result.add(item);
		}
		return ResponseEntity.ok(result);
	}

	@GetMapping("/as-keyholder/{lockId}")
	public ResponseEntity<Map<String, Object>> getLockAsKeyholder(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();

		var lockeeOpt = userRepository.findById(l.getLockee());
		if (lockeeOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var lockee = lockeeOpt.get();

		Map<String, Long> cardCounts = new LinkedHashMap<>();
		if (l.getAvailableCards() != null) {
			l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum));
		}

		boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null;
		boolean hygieneOpeningDue = false;
		long hygieneSecondsRemaining = 0;
		if (hygieneEnabled) {
			LocalDateTime base = l.getLastHygineOpening() != null ? l.getLastHygineOpening() : l.getStartTime();
			if (base != null) {
				LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites());
				hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene);
				hygieneOpeningDue = hygieneSecondsRemaining <= 0;
			}
		}

		boolean verificationDue = false;
		boolean verificationDoneToday = false;
		String verificationMyVote = null; // null = not voted, "upvote", "downvote"
		String verificationTodayId = null;
		String verificationImage = null;
		long verificationUpvotes = 0, verificationDownvotes = 0;
		if (l.isRequiresVerification()) {
			LocalDateTime todayStart = LocalDate.now().atStartOfDay();
			LocalDateTime todayEnd = todayStart.plusDays(1);
			var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId,
					todayStart, todayEnd);
			if (!completed.isEmpty()) {
				verificationDoneToday = true;
				var v = completed.get(0);
				var votes = verificationVoteRepository.findAllByVerificationId(v.getDisplayId());
				verificationUpvotes = votes.stream().filter(CommunityVerificationVoteEntity::isUpvote).count();
				verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count();
				verificationTodayId = v.getDisplayId().toString();
				var myVoteOpt = verificationVoteRepository.findByVerificationIdAndUserId(v.getDisplayId(), myId);
				if (myVoteOpt.isPresent()) {
					verificationMyVote = myVoteOpt.get().isUpvote() ? "upvote" : "downvote";
				} else if (v.getImage() != null) {
					verificationImage = java.util.Base64.getEncoder().encodeToString(v.getImage());
				}
			} else {
				verificationDue = true;
			}
		}

		var notification = keyholderNotificationRepository.findByLockId(lockId).stream()
				.sorted((a, b) -> b.getViolationTime().compareTo(a.getViolationTime())).limit(5)
				.map(v -> Map.of("time", v.getViolationTime().toString(), "overtimeMinutes", v.getOvertimeMinutes(),
						"openingReason", v.getOpeningReason() != null ? v.getOpeningReason().name() : "HYGIENE"))
				.toList();

		Map<String, Object> result = new HashMap<>();
		result.put("lockId", l.getLockId().toString());
		result.put("lockName", l.getName() != null ? l.getName() : "Unbenanntes Lock");
		result.put("lockeeName", lockee.getName());
		result.put("lockeeId", lockee.getUserId().toString());
		result.put("lockeeProfilePic", lockee.getProfilePicture());
		result.put("totalCards", l.getAvailableCards() != null ? l.getAvailableCards().size() : 0);
		result.put("cardCounts", cardCounts);
		result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0);
		result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : null);
		result.put("frozenUntill", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null);
		result.put("taskFrozenUntil", l.getTaskUntil() != null ? l.getTaskUntil().toString() : null);
		boolean isFrozenByKeyholder = l.getFrozenUntil() != null && l.getFrozenUntil().isAfter(LocalDateTime.now());
		result.put("isFrozenByKeyholder", isFrozenByKeyholder);
		result.put("currentTask", l.getCurrentTask());
		result.put("currentTaskDescription", l.getCurrentTaskDescription());
		result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null);
		result.put("hygieneEnabled", hygieneEnabled);
		result.put("hygieneOpeningDue", hygieneOpeningDue);
		result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
		result.put("hygieneOpeningActive", l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE == l.getTempOpeningReason());
		result.put("requiresVerification", l.isRequiresVerification());
		result.put("verificationDue", verificationDue);
		result.put("verificationDoneToday", verificationDoneToday);
		result.put("verificationTodayId", verificationTodayId);
		result.put("verificationMyVote", verificationMyVote);
		result.put("verificationImage", verificationImage);
		result.put("verificationUpvotes", verificationUpvotes);
		result.put("verificationDownvotes", verificationDownvotes);
		result.put("hygieneViolations", notification);
		result.put("hasTasks", l.getTasks() != null && !l.getTasks().isEmpty());
		if (l.getTasks() != null) {
			var taskList = l.getTasks().stream().map(t -> {
				Map<String, Object> m = new LinkedHashMap<>();
				m.put("title", t.getTitle());
				m.put("description", t.getDescription() != null ? t.getDescription() : "");
				m.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0);
				return m;
			}).toList();
			result.put("taskList", taskList);
		} else {
			result.put("taskList", List.of());
		}
		var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream()
				.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).map(t -> {
					Map<String, Object> m = new LinkedHashMap<>();
					m.put("taskId", t.getTaskId().toString());
					m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText());
					m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : "");
					m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0);
					m.put("assignedAt", t.getAssignedAt().toString());
					m.put("acceptDeadline", t.getAcceptDeadline().toString());
					m.put("penaltyFreezeMinutes",
							t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0);
					m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0);
					return m;
				}).toList();
		result.put("pendingAssignedTasks", pendingAssigned);
		result.put("taskMode", l.getTaskMode());

		// Ausstehende Task-Karten-Choices (KEYHOLDER-Modus)
		List<Task> lockTasks = l.getTasks() != null ? l.getTasks() : List.of();
		List<Map<String, Object>> taskListForChoice = new ArrayList<>();
		for (int i = 0; i < lockTasks.size(); i++) {
			Task t = lockTasks.get(i);
			Map<String, Object> tm = new LinkedHashMap<>();
			tm.put("index", i);
			tm.put("title", t.getTitle());
			tm.put("description", t.getDescription() != null ? t.getDescription() : "");
			tm.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0);
			taskListForChoice.add(tm);
		}
		var pendingChoices = keyholderTaskChoiceRepository.findByLockIdAndActiveTrue(lockId).stream().map(c -> {
			Map<String, Object> cm = new LinkedHashMap<>();
			cm.put("choiceId", c.getChoiceId().toString());
			cm.put("createdAt", c.getCreatedAt().toString());
			cm.put("tasks", taskListForChoice);
			return cm;
		}).toList();
		result.put("pendingTaskChoices", pendingChoices);
		result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock());
		result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
		result.put("emergencyUnlockRequestedAt",
				l.getEmergencyUnlockRequestedAt() != null ? l.getEmergencyUnlockRequestedAt().toString() : null);

		return ResponseEntity.ok(result);
	}

	@Transactional
	@DeleteMapping("/cardlock/{lockId}")
	public ResponseEntity<Void> deleteLock(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		// Entsperrung protokollieren (History + XP) – gültig nur wenn Keyholder
		// vorhanden und kein Auto-Notfall
		CardLockService service = cardLockServiceFactory.create(l);
		service.unlock(l.getUnlockCode());

		var verifications = verificationRepository.findByLockId(lockId);
		verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId()));
		verificationRepository.deleteAll(verifications);
		invitationRepository.deleteByLockId(lockId);
		cardlockRepository.deleteById(lockId);
		return ResponseEntity.noContent().build();
	}

	record ModifyCardsRequest(Map<String, Integer> cards, boolean notifyDetailed) {
	}

	@PostMapping("/as-keyholder/{lockId}/cards/add")
	@Transactional
	public ResponseEntity<Void> addCards(@PathVariable("lockId") UUID lockId, @RequestBody ModifyCardsRequest req,
			Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();
		if (req.cards() == null || req.cards().isEmpty())
			return ResponseEntity.badRequest().build();

		List<CardEnum> toAdd = new ArrayList<>();
		for (var entry : req.cards().entrySet()) {
			try {
				CardEnum type = CardEnum.valueOf(entry.getKey());
				int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0;
				for (int i = 0; i < count; i++)
					toAdd.add(type);
			} catch (IllegalArgumentException e) {
				return ResponseEntity.badRequest().build();
			}
		}
		if (toAdd.isEmpty())
			return ResponseEntity.badRequest().build();

		List<CardEnum> cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of());
		cards.addAll(toAdd);
		l.setAvailableCards(cards);
		cardlockRepository.save(l);

		String detail = toAdd.stream().collect(Collectors.groupingBy(c -> c, Collectors.counting())).entrySet().stream()
				.map(e -> e.getValue() + "x " + cardLabel(e.getKey())).collect(Collectors.joining(", "));
		String msgText = req.notifyDetailed()
				? me.getName() + " hat " + toAdd.size() + " Karte(n) zu deinem Lock hinzugefügt: " + detail + "."
				: me.getName() + " hat Karten zu deinem Lock hinzugefügt.";

		sendMessage(myId, l.getLockee(), msgText, "/games/chastity/activelock.html?lockId=" + lockId,
				de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	@PostMapping("/as-keyholder/{lockId}/cards/remove")
	@Transactional
	public ResponseEntity<Map<String, String>> removeCards(@PathVariable("lockId") UUID lockId,
			@RequestBody ModifyCardsRequest req, Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();
		if (req.cards() == null || req.cards().isEmpty())
			return ResponseEntity.badRequest().build();

		List<CardEnum> cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of());

		// Plausi: letzte grüne Karte darf nicht entfernt werden
		long greenInDeck = cards.stream().filter(c -> c == CardEnum.GREEN).count();
		int greenToRemove = req.cards().getOrDefault("GREEN", 0);
		if (greenInDeck > 0 && greenToRemove >= greenInDeck) {
			return ResponseEntity.badRequest()
					.body(Map.of("error", "Die letzte grüne Karte darf nicht entfernt werden."));
		}

		List<CardEnum> removed = new ArrayList<>();
		for (var entry : req.cards().entrySet()) {
			try {
				CardEnum type = CardEnum.valueOf(entry.getKey());
				int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0;
				Iterator<CardEnum> it = cards.iterator();
				int done = 0;
				while (it.hasNext() && done < count) {
					if (it.next() == type) {
						it.remove();
						removed.add(type);
						done++;
					}
				}
			} catch (IllegalArgumentException e) {
				return ResponseEntity.badRequest().build();
			}
		}
		if (removed.isEmpty())
			return ResponseEntity.badRequest().build();

		l.setAvailableCards(cards);
		cardlockRepository.save(l);

		String detail = removed.stream().collect(Collectors.groupingBy(c -> c, Collectors.counting())).entrySet()
				.stream().map(e -> e.getValue() + "x " + cardLabel(e.getKey())).collect(Collectors.joining(", "));
		String msgText = req.notifyDetailed()
				? me.getName() + " hat " + removed.size() + " Karte(n) aus deinem Lock entfernt: " + detail + "."
				: me.getName() + " hat Karten aus deinem Lock entfernt.";

		sendMessage(myId, l.getLockee(), msgText, "/games/chastity/activelock.html?lockId=" + lockId,
				de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	// ── Hilfsmethoden ──────────────────────────────────────────────────────────

	private void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl,
			de.oaa.xxx.social.entity.MessageCause cause) {
		systemMessageService.send(senderId, receiverId, text, targetUrl, cause);
	}

	@GetMapping("/cardlock/unlock-history")
	public ResponseEntity<List<Map<String, Object>>> getUnlockHistory(Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();
		var entries = unlockCodeHistoryRepository.findByUserIdOrderByReceivedAtDesc(myId,
				org.springframework.data.domain.PageRequest.of(0, 10));
		List<Map<String, Object>> result = new ArrayList<>();
		for (var e : entries) {
			Map<String, Object> item = new HashMap<>();
			item.put("lockName", e.getLockName());
			item.put("unlockCode", e.getUnlockCode());
			item.put("source", e.getSource());
			item.put("receivedAt", e.getReceivedAt().toString());
			result.add(item);
		}
		return ResponseEntity.ok(result);
	}

	// ── Keyholder: Aufgabe stellen ─────────────────────────────────────────────

	record AssignTaskRequest(int taskIndex, int acceptDeadlineMinutes, Integer penaltyFreezeMinutes,
			Integer penaltyRedCards) {
	}

	@Transactional
	@PostMapping("/as-keyholder/{lockId}/assign-task")
	public ResponseEntity<?> assignTask(@PathVariable("lockId") UUID lockId, @RequestBody AssignTaskRequest req,
			Principal principal) {
		var me = userService.requireUser(principal);

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!me.getUserId().equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();

		var tasks = l.getTasks();
		if (tasks == null || tasks.isEmpty())
			return ResponseEntity.badRequest().body(Map.of("error", "Dieses Lock hat keine Aufgaben."));
		if (req.taskIndex() < 0 || req.taskIndex() >= tasks.size())
			return ResponseEntity.badRequest().body(Map.of("error", "Ungültiger Aufgaben-Index."));
		if (req.acceptDeadlineMinutes() < 1)
			return ResponseEntity.badRequest()
					.body(Map.of("error", "Die Annahme-Frist muss mindestens 1 Minute betragen."));

		long pendingCount = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream()
				.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).count();
		if (pendingCount >= 5)
			return ResponseEntity.badRequest().body(Map.of("error",
					"Es sind bereits 5 Aufgaben offen. Bitte warte, bis der Lockee eine davon annimmt oder ablehnt."));

		Task task = tasks.get(req.taskIndex());
		AssignedTaskEntity assigned = new AssignedTaskEntity();
		assigned.setLockId(lockId);
		assigned.setTaskTitle(task.getTitle());
		assigned.setTaskDescription(task.getDescription());
		assigned.setTaskText(task.getTitle()); // Compat
		assigned.setTaskMinutes(task.getMinutes());
		assigned.setAssignedAt(LocalDateTime.now());
		assigned.setAcceptDeadline(LocalDateTime.now().plusMinutes(req.acceptDeadlineMinutes()));
		assigned.setPenaltyFreezeMinutes(req.penaltyFreezeMinutes());
		assigned.setPenaltyRedCards(req.penaltyRedCards());
		assigned.setStatus("PENDING");
		assignedTaskRepository.save(assigned);

		sendMessage(me.getUserId(), l.getLockee(),
				me.getName() + " hat dir eine Aufgabe gestellt. Du hast " + req.acceptDeadlineMinutes()
						+ " Minuten, um sie anzunehmen.",
				"/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	// ── Lockee: Aufgabe annehmen ───────────────────────────────────────────────

	@Transactional
	@PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/accept")
	public ResponseEntity<?> acceptAssignedTask(@PathVariable("lockId") UUID lockId, @PathVariable("taskId") UUID taskId,
			Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		var taskOpt = assignedTaskRepository.findById(taskId);
		if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
			return ResponseEntity.notFound().build();
		var task = taskOpt.get();
		if (!"PENDING".equals(task.getStatus()))
			return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend."));
		if (task.getAcceptDeadline().isBefore(LocalDateTime.now())) {
			// Bereits abgelaufen – Strafe anwenden
			task.setStatus("EXPIRED");
			cardLockServiceFactory.create(l).applyAssignedTaskPenalty(task);
			assignedTaskRepository.save(task);
			return ResponseEntity.status(409)
					.body(Map.of("error", "Die Annahme-Frist ist abgelaufen. Die Strafe wurde angewendet."));
		}
		boolean hasActiveTask = (l.getCurrentTask() != null && !l.getCurrentTask().isBlank())
				|| (l.getTaskUntil() != null && l.getTaskUntil().isAfter(LocalDateTime.now()));
		if (hasActiveTask)
			return ResponseEntity.status(409).body(Map.of("error", "Du hast bereits eine laufende Aufgabe."));

		// Aufgabe aktivieren – separater Task-Timer, kein Freeze
		String title = task.getTaskTitle() != null ? task.getTaskTitle() : task.getTaskText();
		l.setCurrentTask(title);
		l.setCurrentTaskDescription(task.getTaskDescription());
		if (task.getTaskMinutes() != null && task.getTaskMinutes() > 0) {
			l.setTaskUntil(LocalDateTime.now().plusMinutes(task.getTaskMinutes()));

			// Fälligkeit aller anderen offenen Aufgaben um die Task-Dauer verschieben
			final int extraMinutes = task.getTaskMinutes();
			assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream()
					.filter(t -> !t.getTaskId().equals(taskId)).forEach(t -> {
						t.setAcceptDeadline(t.getAcceptDeadline().plusMinutes(extraMinutes));
						assignedTaskRepository.save(t);
					});
		}
		task.setStatus("ACCEPTED");
		assignedTaskRepository.save(task);
		cardlockRepository.save(l);

		String meName = userRepository.findById(myId).map(u -> u.getName()).orElse("");
		sendMessage(myId, l.getKeyholder(), meName + " hat die gestellte Aufgabe angenommen.",
				"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
		return ResponseEntity.noContent().build();
	}

	// ── Lockee: Aufgabe ablehnen ───────────────────────────────────────────────

	@Transactional
	@PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/decline")
	public ResponseEntity<?> declineAssignedTask(@PathVariable("lockId") UUID lockId, @PathVariable("taskId") UUID taskId,
			Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();

		var taskOpt = assignedTaskRepository.findById(taskId);
		if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
			return ResponseEntity.notFound().build();
		var task = taskOpt.get();
		if (!"PENDING".equals(task.getStatus()))
			return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend."));

		task.setStatus("DECLINED");
		cardLockServiceFactory.create(l).applyAssignedTaskPenalty(task);
		assignedTaskRepository.save(task);

		String meNameDecl = userRepository.findById(myId).map(u -> u.getName()).orElse("");
		sendMessage(myId, l.getKeyholder(),
				meNameDecl + " hat die gestellte Aufgabe abgelehnt. Die Strafe wurde angewendet.",
				"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
		return ResponseEntity.noContent().build();
	}

	// ── Keyholder: Aufgabe zurückziehen ───────────────────────────────────────

	@Transactional
	@DeleteMapping("/as-keyholder/{lockId}/assigned-tasks/{taskId}")
	public ResponseEntity<?> cancelAssignedTask(@PathVariable("lockId") UUID lockId, @PathVariable("taskId") UUID taskId,
			Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();

		var taskOpt = assignedTaskRepository.findById(taskId);
		if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
			return ResponseEntity.notFound().build();
		var task = taskOpt.get();
		if (!"PENDING".equals(task.getStatus()))
			return ResponseEntity.status(409).body(Map.of("error", "Aufgabe ist nicht mehr ausstehend."));

		assignedTaskRepository.delete(task);
		return ResponseEntity.noContent().build();
	}

	record FreezeRequest(String frozenUntil) {
	}

	@Transactional
	@PostMapping("/as-keyholder/{lockId}/freeze")
	public ResponseEntity<?> freezeLock(@PathVariable("lockId") UUID lockId, @RequestBody FreezeRequest req,
			Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();
		if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) {
			return ResponseEntity.badRequest()
					.body(Map.of("error", "Das Lock ist gerade durch eine Aufgabe eingefroren."));
		}

		LocalDateTime until;
		try {
			until = LocalDateTime.parse(req.frozenUntil());
		} catch (Exception e) {
			return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat."));
		}
		if (!until.isAfter(LocalDateTime.now())) {
			return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen."));
		}

		l.setFrozenUntil(until);
		cardlockRepository.save(l);

		sendMessage(myId, l.getLockee(),
				me.getName() + " hat dein Lock bis "
						+ until.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")) + " "
						+ until.toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
						+ " Uhr eingefroren.",
				"/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	@Transactional
	@DeleteMapping("/as-keyholder/{lockId}/freeze")
	public ResponseEntity<?> unfreezeLock(@PathVariable("lockId") UUID lockId, Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();
		if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) {
			return ResponseEntity.badRequest().body(Map.of("error",
					"Das Lock ist durch eine Aufgabe eingefroren und kann nicht manuell entfroren werden."));
		}

		l.setFrozenUntil(null);
		cardlockRepository.save(l);

		sendMessage(myId, l.getLockee(), me.getName() + " hat dein Lock wieder entfroren.",
				"/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	@Transactional
	@PostMapping("/as-keyholder/{lockId}/request-unlock")
	public ResponseEntity<?> requestUnlock(@PathVariable("lockId") UUID lockId, Principal principal) {
		UUID myId = userService.requireUser(principal).getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!myId.equals(l.getKeyholder()))
			return ResponseEntity.status(403).build();

		l.setKeyholderRequestedUnlock(true);
		cardlockRepository.save(l);

		sendMessage(myId, l.getLockee(),
				"Dein Keyholder hat das Lock freigeschaltet. Du erhältst beim nächsten Laden deinen Entsperrcode.",
				"/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);

		return ResponseEntity.noContent().build();
	}

	@Transactional
	@PostMapping("/cardlock/{lockId}/emergency-unlock")
	public ResponseEntity<?> requestEmergencyUnlock(@PathVariable("lockId") UUID lockId, Principal principal) {
		var me = userService.requireUser(principal);
		UUID myId = me.getUserId();

		var lockOpt = cardlockRepository.findById(lockId);
		if (lockOpt.isEmpty())
			return ResponseEntity.notFound().build();
		var l = lockOpt.get();
		if (!l.getLockee().equals(myId))
			return ResponseEntity.status(403).build();
		if (l.isTestLock())
			return ResponseEntity.badRequest().build();
		if (l.getEmergencyUnlockRequestedAt() != null)
			return ResponseEntity.status(409).build();

		l.setEmergencyUnlockRequestedAt(LocalDateTime.now());

		if (l.getKeyholder() == null) {
			// Self-Lock ohne Keyholderin → sofort öffnen
			l.setEmergencyAutoUnlocked(true);
			l.setKeyholderRequestedUnlock(true);
		} else {
			// Keyholderin benachrichtigen
			sendMessage(myId, l.getKeyholder(), "⚠️ NOTFALL: " + me.getName()
					+ " bittet dringend um Freigabe des Locks. Bitte reagiere innerhalb einer Stunde, sonst öffnet sich das Lock automatisch.",
					"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.EMERGENCY);
		}
		cardlockRepository.save(l);
		return ResponseEntity.noContent().build();
	}

	private String cardLabel(CardEnum card) {
		return switch (card) {
		case RED -> "Rote Karte";
		case GREEN -> "Grüne Karte";
		case YELLOW -> "Gelbe Karte";
		case TASK -> "Aufgabe";
		case FREEZE -> "Freeze";
		case RESET -> "Reset";
		case DOUBLE_UP -> "Double Up";
		case CUM -> "Kommen";
		case CUM_IN_CAGE -> "Kommen im Käfig";
		};
	}

	@GetMapping("/invitation/{token}")
	public void confirmInvitation(@PathVariable String token, jakarta.servlet.http.HttpServletResponse response)
			throws Exception {
		var invOpt = invitationRepository.findByToken(token);
		if (invOpt.isEmpty()) {
			response.sendRedirect("/games/chastity/keyholder-invitation-confirmed.html?status=invalid");
			return;
		}
		var inv = invOpt.get();
		var lockOpt = cardlockRepository.findById(inv.getLockId());
		if (lockOpt.isEmpty()) {
			response.sendRedirect("/games/chastity/keyholder-invitation-confirmed.html?status=invalid");
			return;
		}
		var lock = lockOpt.get();
		lock.setKeyholder(inv.getKeyholderUserId());
		cardlockRepository.save(lock);
		invitationRepository.delete(inv);
		response.sendRedirect("/games/chastity/keyholder.html");
	}
}
