package org.raddatz.familienarchiv.geschichte.journeyitem;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;

@Service
@RequiredArgsConstructor
@Slf4j
public class JourneyItemService {

    static final int MAX_ITEMS = 100;
    static final int POSITION_STEP = 10;
    // 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
    static final int MAX_NOTE_LENGTH = 2000;

    private final JourneyItemRepository journeyItemRepository;
    private final GeschichteQueryService geschichteQueryService;
    private final DocumentService documentService;
    private final AuditService auditService;
    private final UserService userService;

    @Transactional
    public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
        Geschichte g = geschichteQueryService.findById(geschichteId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
                        "Geschichte not found: " + geschichteId));

        long count = journeyItemRepository.countByGeschichteId(geschichteId);
        if (count >= MAX_ITEMS) {
            throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
                    "Journey has reached the maximum of 100 items");
        }

        String note = normalizeNote(dto.getNote());

        if (dto.getDocumentId() == null && note == null) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
                    "At least one of documentId or note must be provided");
        }

        if (note != null && note.length() > MAX_NOTE_LENGTH) {
            throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
                    "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
        }

        Document doc = null;
        if (dto.getDocumentId() != null) {
            if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
                throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
                        "Document already in journey: " + dto.getDocumentId());
            }
            doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
        }

        int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
                .map(max -> max + POSITION_STEP)
                .orElse(POSITION_STEP);

        JourneyItem item = JourneyItem.builder()
                .geschichte(g)
                .position(nextPosition)
                .document(doc)
                .note(note)
                .build();
        // saveAndFlush so the partial unique index on (geschichte_id, document_id)
        // fires here, not at commit — two concurrent appends can both pass the
        // exists() pre-check above, and the index is the atomic backstop (V74).
        JourneyItem saved;
        try {
            saved = journeyItemRepository.saveAndFlush(item);
        } catch (DataIntegrityViolationException e) {
            // Only the dedup index earns the friendly 409 — any other integrity
            // failure (e.g. an FK violation on a concurrently deleted document)
            // must not be mislabeled as "already added".
            if (!isDuplicateDocumentViolation(e)) {
                throw e;
            }
            throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
                    "Document already in journey: " + dto.getDocumentId());
        }

        UUID actorId = currentUser().getId();
        auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
                Map.of("geschichteId", geschichteId, "itemId", saved.getId()));

        return toView(saved);
    }

    @Transactional
    public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
        JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
                        "Journey item not found: " + itemId));

        // null = field absent from JSON → no-op
        Optional<String> noteField = dto.getNote();
        if (noteField == null) {
            return toView(item);
        }

        String note = normalizeNote(noteField.orElse(null));

        if (note != null && note.length() > MAX_NOTE_LENGTH) {
            throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
                    "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
        }

        if (note == null && item.getDocumentId() == null) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
                    "Cannot clear note on an item that has no linked document");
        }

        item.setNote(note);
        JourneyItem saved = journeyItemRepository.save(item);

        UUID actorId = currentUser().getId();
        auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
                Map.of("geschichteId", geschichteId, "itemId", itemId));

        return toView(saved);
    }

    @Transactional
    public void delete(UUID geschichteId, UUID itemId) {
        JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
                        "Journey item not found: " + itemId));

        journeyItemRepository.delete(item);

        UUID actorId = currentUser().getId();
        auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
                Map.of("geschichteId", geschichteId, "itemId", itemId));
    }

    @Transactional
    public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
        if (!geschichteQueryService.existsById(geschichteId)) {
            throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
                    "Geschichte not found: " + geschichteId);
        }
        Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
        List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();

        if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
                    "Duplicate item IDs in reorder request");
        }

        if (!existingIds.equals(new HashSet<>(requestedIds))) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
                    "Requested item IDs do not match the journey's existing items");
        }

        if (requestedIds.isEmpty()) {
            return List.of();
        }

        List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
        Map<UUID, JourneyItem> itemMap = new HashMap<>();
        for (JourneyItem item : items) {
            itemMap.put(item.getId(), item);
        }

        List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
        for (int i = 0; i < requestedIds.size(); i++) {
            JourneyItem item = itemMap.get(requestedIds.get(i));
            item.setPosition((i + 1) * POSITION_STEP);
            toSave.add(item);
        }
        List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);

        UUID actorId = currentUser().getId();
        auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
                Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));

        return reordered.stream().map(this::toView).toList();
    }

    public List<JourneyItemView> getItems(UUID geschichteId) {
        return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
                .stream().map(this::toView).toList();
    }

    DocumentSummary toSummary(Document doc) {
        String senderName = buildSenderName(doc);
        Set<Person> receivers = doc.getReceivers();
        String receiverName = buildCanonicalReceiverName(receivers);

        return new DocumentSummary(
                doc.getId(),
                doc.getTitle(),
                doc.getDocumentDate(),
                doc.getMetaDateEnd(),
                doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
                senderName,
                receiverName,
                receivers != null ? receivers.size() : 0
        );
    }

    JourneyItemView toView(JourneyItem item) {
        DocumentSummary docSummary = null;
        Document doc = item.getDocument();
        if (doc != null) {
            docSummary = toSummary(doc);
        }
        return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
    }

    private static String buildSenderName(Document doc) {
        Person sender = doc.getSender();
        if (sender != null) {
            String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
            if (!name.isBlank()) return name;
        }
        String senderText = doc.getSenderText();
        return (senderText != null && !senderText.isBlank()) ? senderText : null;
    }

    private static String buildCanonicalReceiverName(Set<Person> receivers) {
        if (receivers == null || receivers.isEmpty()) return null;
        return receivers.stream()
                .min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
                .map(p -> {
                    String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
                    return name.isBlank() ? null : name;
                })
                .orElse(null);
    }

    private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
        Throwable cause = e.getCause();
        if (cause instanceof java.sql.SQLException sql) {
            return "23505".equals(sql.getSQLState());
        }
        return false;
    }

    private static String normalizeNote(String raw) {
        if (raw == null || raw.isBlank()) return null;
        return raw.trim();
    }

    private static String sortKey(String s) {
        return s != null ? s : "";
    }

    private AppUser currentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            throw DomainException.unauthorized("Authentication required");
        }
        return userService.findByEmail(auth.getName());
    }
}
