package org.raddatz.familienarchiv.document;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
import org.raddatz.familienarchiv.document.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.document.MatchOffset;
import org.raddatz.familienarchiv.document.SearchMatchData;
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionQueueProjection;
import org.raddatz.familienarchiv.document.transcription.TranscriptionWeeklyStatsProjection;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.ocr.ScriptType;
import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;

@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentService {

    private final DocumentRepository documentRepository;
    private final DocumentTitleFactory documentTitleFactory;
    private final PersonService personService;
    private final FileService fileService;
    private final TagService tagService;
    private final DocumentVersionService documentVersionService;
    private final AnnotationService annotationService;
    private final AuditService auditService;
    private final TranscriptionBlockQueryService transcriptionBlockQueryService;
    private final AuditLogQueryService auditLogQueryService;
    private final ThumbnailAsyncRunner thumbnailAsyncRunner;
    private final ApplicationEventPublisher eventPublisher;

    public record StoreResult(Document document, boolean isNew) {}

    public long count() {
        return documentRepository.count();
    }

    public Optional<Document> findById(UUID id) {
        return documentRepository.findById(id);
    }

    public List<Document> findForThumbnailBackfill() {
        return documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
    }

    public Optional<Document> findByOriginalFilename(String originalFilename) {
        return documentRepository.findByOriginalFilename(originalFilename);
    }

    public Document save(Document doc) {
        return documentRepository.save(doc);
    }

    public List<TranscriptionQueueProjection> findSegmentationQueue(int limit) {
        return documentRepository.findSegmentationQueue(limit);
    }

    public List<TranscriptionQueueProjection> findTranscriptionQueue(int limit) {
        return documentRepository.findTranscriptionQueue(limit);
    }

    public List<TranscriptionQueueProjection> findReadyToReadQueue(int limit) {
        return documentRepository.findReadyToReadQueue(limit);
    }

    public TranscriptionWeeklyStatsProjection findWeeklyStats() {
        return documentRepository.findWeeklyStats();
    }

    public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) {
        if (ids.isEmpty()) return Map.of();
        Map<UUID, String> titles = new HashMap<>();
        for (Object[] row : documentRepository.findIdAndTitleByIdIn(ids)) {
            titles.put((UUID) row[0], (String) row[1]);
        }
        return titles;
    }

    /**
     * Per-month document counts for the timeline density widget (issue #385).
     *
     * <p>Filter-reactive: the chart recomputes when other filters (sender,
     * receiver, tag, q, status) change so it always matches the list it sits
     * above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
     * the surface for picking those, so it must always span the broader space
     * the user is selecting within.
     *
     * <p>Implementation note: groups in memory rather than via SQL GROUP BY
     * because the existing {@link Specification} predicates compose easily
     * with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
     * well under the 200ms p95 target. The controller sets no explicit
     * Cache-Control, so the response is served fresh on every load (issue
     * #709) — the recompute is imperceptible and stale month counts after an
     * edit would be misleading on an interactive chart.
     *
     * <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
     * — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
     * 'YYYY-MM')) and accept that the criteria/specification surface needs a
     * parallel native-query path.
     */
    public DocumentDensityResult getDensity(DensityFilters filters) {
        List<UUID> ftsIds = resolveFtsIds(filters.text());
        if (ftsIds != null && ftsIds.isEmpty()) {
            return DocumentDensityResult.empty();
        }
        List<LocalDate> dates = loadFilteredDates(filters, ftsIds);
        return aggregateByMonth(dates);
    }

    /**
     * Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null}
     * when no full-text query is active. An empty list means the FTS query ran but
     * matched zero documents — the caller short-circuits on that signal.
     */
    private List<UUID> resolveFtsIds(String text) {
        if (!StringUtils.hasText(text)) return null;
        return documentRepository.findAllMatchingIdsByFts(text);
    }

    /** Loads matching documents and projects to non-null {@link LocalDate}s. */
    private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
        boolean hasFts = ftsIds != null;
        // Density and search keep separate filter records (DensityFilters has no
        // date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
        // Date bounds stay null and undated=false — the density path never filters by date.
        SearchFilters searchFilters = new SearchFilters(
                filters.text(), null, null, filters.sender(), filters.receiver(),
                filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
        Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
        return documentRepository.findAll(spec).stream()
                .map(Document::getDocumentDate)
                .filter(Objects::nonNull)
                .toList();
    }

    /** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */
    private DocumentDensityResult aggregateByMonth(List<LocalDate> dates) {
        if (dates.isEmpty()) return DocumentDensityResult.empty();
        Map<String, Integer> counts = new java.util.TreeMap<>();
        for (LocalDate d : dates) {
            counts.merge(YearMonth.from(d).toString(), 1, Integer::sum);
        }
        List<MonthBucket> buckets = counts.entrySet().stream()
                .map(e -> new MonthBucket(e.getKey(), e.getValue()))
                .toList();
        LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
        LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
        return new DocumentDensityResult(buckets, minDate, maxDate);
    }

    /**
     * Lädt eine Datei hoch.
     * - Prüft, ob ein Eintrag (aus Excel) schon existiert.
     * - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
     * - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
     */
    @Transactional
    public StoreResult storeDocument(MultipartFile file, UUID actorId) throws IOException {
        String originalFilename = file.getOriginalFilename();

        // 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
        Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
        boolean isNew = existingDoc.isEmpty();
        Document document;

        if (existingDoc.isPresent()) {
            document = existingDoc.get();
        } else {
            // New uploads from the drop zone always start as incomplete
            ParsedFilename parsed = parseFilenameData(originalFilename);
            Person sender = (parsed != null)
                    ? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
                    : null;
            document = Document.builder()
                    .originalFilename(originalFilename)
                    .title(parsed != null ? parsed.title() : stripExtension(originalFilename))
                    .documentDate(parsed != null ? parsed.date() : null)
                    .sender(sender)
                    .status(DocumentStatus.UPLOADED)
                    .metadataComplete(false)
                    .build();
        }

        // 2. Delegate Storage to FileService
        FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);

        // 3. Update Database
        document.setFilePath(upload.s3Key());
        document.setFileHash(upload.fileHash());
        document.setContentType(file.getContentType());
        boolean wasPlaceholder = document.getStatus() == DocumentStatus.PLACEHOLDER;
        if (wasPlaceholder) {
            document.setStatus(DocumentStatus.UPLOADED);
        }

        Document saved = documentRepository.save(document);
        if (wasPlaceholder) {
            auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
        }
        thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
        return new StoreResult(saved, isNew);
    }

    public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
        // 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
        if (fileCount > 50) {
            throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
        }
        if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
        }
    }

    @Transactional
    public StoreResult storeDocumentWithBatchMetadata(
            MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
        StoreResult base = storeDocument(file, actorId);
        Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
        return new StoreResult(documentRepository.save(doc), base.isNew());
    }

    private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
        if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
            doc.setTitle(metadata.getTitles().get(fileIndex));
        }
        if (metadata.getSenderId() != null) {
            doc.setSender(personService.getById(metadata.getSenderId()));
        }
        if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
            doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
        }
        if (metadata.getDocumentDate() != null) {
            doc.setDocumentDate(metadata.getDocumentDate());
        }
        if (metadata.getLocation() != null) {
            doc.setLocation(metadata.getLocation());
        }
        if (metadata.getMetadataComplete() != null) {
            doc.setMetadataComplete(metadata.getMetadataComplete());
        }
        if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
            UUID docId = doc.getId();
            updateDocumentTags(docId, metadata.getTagNames());
            doc = documentRepository.findById(docId)
                    .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
        }
        return doc;
    }

    @Transactional
    public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
        String filename = (file != null && !file.isEmpty())
                ? file.getOriginalFilename()
                : (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");

        // If the caller explicitly sets metadataComplete, use it.
        // Otherwise apply heuristic: complete if at least one key field is present.
        boolean metadataComplete;
        if (dto.getMetadataComplete() != null) {
            metadataComplete = dto.getMetadataComplete();
        } else {
            metadataComplete = dto.getDocumentDate() != null
                    || dto.getSenderId() != null
                    || (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
        }

        String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
                ? dto.getTitle()
                : titleFromFilename(filename);

        Document doc = Document.builder()
                .originalFilename(filename)
                .title(titleToUse)
                .documentDate(dto.getDocumentDate())
                .location(dto.getLocation())
                .documentLocation(dto.getDocumentLocation())
                .transcription(dto.getTranscription())
                .summary(dto.getSummary())
                .status(DocumentStatus.PLACEHOLDER)
                .metadataComplete(metadataComplete)
                .build();

        doc = documentRepository.save(doc);

        // Tags
        List<String> tags = new ArrayList<>();
        if (dto.getTags() != null && !dto.getTags().isBlank()) {
            tags = Arrays.stream(dto.getTags().split(","))
                    .map(String::trim)
                    .filter(s -> !s.isEmpty())
                    .toList();
        }
        UUID savedId = doc.getId();
        updateDocumentTags(savedId, tags);
        doc = documentRepository.findById(savedId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));

        // Sender
        if (dto.getSenderId() != null) {
            doc.setSender(personService.getById(dto.getSenderId()));
        }

        // Empfänger
        if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
            doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
        }

        // Datei
        boolean fileUploaded = file != null && !file.isEmpty();
        if (fileUploaded) {
            FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
            doc.setFilePath(upload.s3Key());
            doc.setFileHash(upload.fileHash());
            doc.setContentType(file.getContentType());
            doc.setStatus(DocumentStatus.UPLOADED);
        }

        Document finalDoc = documentRepository.save(doc);
        documentVersionService.recordVersion(finalDoc);
        if (fileUploaded) {
            thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
        }
        return finalDoc;
    }

    @Transactional
    public Document updateDocument(UUID id, DocumentUpdateDTO dto, MultipartFile newFile, UUID actorId) throws IOException {
        Document doc = documentRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));

        DocumentStatus statusBefore = doc.getStatus();

        // Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
        // BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
        // skips nulls, so the old state must be read first. The submitted title is the catalog
        // auto-title iff it equals this; only then does it follow date/location forward.
        String autoTitleBefore = documentTitleFactory.build(doc);

        // 1. Einfache Felder Update
        doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
        doc.setDocumentDate(dto.getDocumentDate());
        applyDatePrecision(doc, dto);
        validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
        doc.setLocation(dto.getLocation());
        doc.setTranscription(dto.getTranscription());
        doc.setSummary(dto.getSummary());
        doc.setDocumentLocation(dto.getDocumentLocation());
        doc.setArchiveBox(dto.getArchiveBox());
        doc.setArchiveFolder(dto.getArchiveFolder());

        List<String> tags = new ArrayList<>();
        if (dto.getTags() != null && !dto.getTags().isBlank()) {
            // Split by comma, strip whitespace
            tags = Arrays.stream(dto.getTags().split(","))
                    .map(String::trim)
                    .filter(s -> !s.isEmpty())
                    .toList();
        }
        updateDocumentTags(doc.getId(), tags);

        // 2. Sender verknüpfen
        if (dto.getSenderId() != null) {
            doc.setSender(personService.getById(dto.getSenderId()));
        } else {
            doc.setSender(null);
        }

        // 3. Empfänger verknüpfen
        if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
            doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
        } else {
            doc.getReceivers().clear(); // Alle entfernen
        }

        // 3b. metadataComplete — only update when explicitly set in the DTO
        if (dto.getMetadataComplete() != null) {
            doc.setMetadataComplete(dto.getMetadataComplete());
        }

        if (dto.getScriptType() != null) {
            doc.setScriptType(dto.getScriptType());
        }

        // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
        // NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
        // segment is originalFilename, so after a replace the stored title no longer matches
        // build(currentState) and the row is treated as manual — neither save-time nor backfill
        // rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
        boolean fileReplaced = newFile != null && !newFile.isEmpty();
        if (fileReplaced) {
            FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
            doc.setFilePath(upload.s3Key());
            doc.setFileHash(upload.fileHash());
            doc.setOriginalFilename(newFile.getOriginalFilename());
            doc.setContentType(newFile.getContentType());
            doc.setStatus(DocumentStatus.UPLOADED);
        }

        Document saved = documentRepository.save(doc);
        documentVersionService.recordVersion(saved);

        if (saved.getStatus() != statusBefore) {
            auditService.logAfterCommit(AuditKind.STATUS_CHANGED, actorId, saved.getId(),
                    Map.of("oldStatus", statusBefore.name(), "newStatus", saved.getStatus().name()));
        } else {
            auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
        }

        if (fileReplaced) {
            thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
        }

        return saved;
    }

    /**
     * Decides the title to persist on an edit (#726). The submitted title is the catalog
     * auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
     * comparison with no heuristic, relying on the edit form round-tripping the stored title
     * verbatim when untouched. A machine title is rebuilt from the new state so a corrected
     * date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
     * submission is never persisted (title is always present) — it falls back to the rebuilt
     * auto-title, which always carries at least the index.
     */
    private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
        if (submitted == null || submitted.isBlank()) {
            return documentTitleFactory.build(projectedState(doc, dto));
        }
        if (!Objects.equals(submitted, autoBefore)) {
            return submitted;
        }
        return documentTitleFactory.build(projectedState(doc, dto));
    }

    /**
     * The document state the regenerated title is built from. It is composed from the SAME
     * resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
     * DTO (a null value clears the field), precision/end/raw resolved skip-null via
     * {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
     * the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
     * is never touched by a metadata edit.
     */
    private Document projectedState(Document doc, DocumentUpdateDTO dto) {
        return Document.builder()
                .originalFilename(doc.getOriginalFilename())
                .documentDate(dto.getDocumentDate())
                .location(dto.getLocation())
                .metaDatePrecision(effectivePrecision(doc, dto))
                .metaDateEnd(effectiveMetaDateEnd(doc, dto))
                .metaDateRaw(effectiveMetaDateRaw(doc, dto))
                .build();
    }

    /**
     * Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
     * so the stored value is kept rather than overwritten with null — which would fabricate a
     * precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
     * the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
     * the stored value back when the DTO omits a field is a harmless no-op).
     */
    private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
        doc.setMetaDatePrecision(effectivePrecision(doc, dto));
        doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
        doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
    }

    // Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
    // projectedState (the title projection) — the single rule keeps them from diverging (#726).
    private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
        return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
    }

    private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
        return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
    }

    private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
        return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
    }

    /**
     * Friendly guard for the two V69 date-range CHECK constraints, run before save so a
     * user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to
     * the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc}
     * state, not the DTO, because precision/end may have been carried over from the stored row
     * when the DTO field was null. The DB CHECK remains the backstop; this never weakens it.
     */
    private void validateDateRange(Document doc) {
        // Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed.
        // Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=.
        if (doc.getMetaDatePrecision() == DatePrecision.RANGE
                && doc.getDocumentDate() != null
                && doc.getMetaDateEnd() != null
                && doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) {
            throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
                    "meta_date_end must not be before meta_date");
        }
        // Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the
        // end field off-RANGE, so this branch closes the same 500 class for direct clients.
        if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) {
            throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
                    "meta_date_end is only allowed when meta_date_precision is RANGE");
        }
    }

    @Transactional
    public Document updateDocumentTags(UUID docId, List<String> tagNames) {
        Document doc = documentRepository.findById(docId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
        doc.setTags(resolveTags(tagNames));
        return documentRepository.save(doc);
    }

    /**
     * Resolves a list of tag-name strings to {@link Tag} entities, trimming
     * whitespace and skipping blank entries. Single source of truth for
     * "name string → Tag" so the find-or-create policy stays consistent
     * across single-doc updates ({@link #updateDocumentTags}), bulk edits
     * ({@link #applyBulkEditToDocument}), and the upload-batch path
     * ({@code applyBatchMetadata}).
     */
    private Set<Tag> resolveTags(List<String> tagNames) {
        if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
        Set<Tag> resolved = new HashSet<>();
        for (String name : tagNames) {
            String cleanName = name.trim();
            if (cleanName.isEmpty()) continue;
            resolved.add(tagService.findOrCreate(cleanName));
        }
        return resolved;
    }

    /**
     * Returns all document IDs matching the given filter parameters, ignoring
     * pagination. Used by the bulk-edit "Alle X editieren" fast path so the
     * frontend can replace the selection with every match across pages in one
     * round-trip.
     */
    @Transactional(readOnly = true)
    public List<UUID> findIdsForFilter(SearchFilters filters) {
        boolean hasText = StringUtils.hasText(filters.text());
        List<UUID> rankedIds = null;
        if (hasText) {
            rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
            if (rankedIds.isEmpty()) return List.of();
        }

        Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
        return documentRepository.findAll(spec).stream().map(Document::getId).toList();
    }

    /**
     * Single source of truth for the search Specification chain. Shared by
     * {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
     * (uncapped, ID-only). Caller does its own FTS short-circuit when the
     * full-text query returned no rows.
     */
    private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
        boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
        List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
        Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
        return Specification.where(textSpec)
                .and(isBetween(filters.from(), filters.to()))
                .and(hasSender(filters.sender()))
                .and(hasReceiver(filters.receiver()))
                .and(hasTags(expandedTagSets, useOrLogic))
                .and(hasTagPartial(filters.tagQ()))
                .and(hasStatus(filters.status()))
                .and(undatedOnly(filters.undated()));
    }

    /**
     * Returns lightweight summaries (id, title, server PDF URL) for the given
     * document IDs. Unknown IDs are silently dropped — the consumer is the
     * bulk-edit page's left strip, where missing previews would already be
     * obvious; surfacing them as errors here adds no value.
     */
    @Transactional(readOnly = true)
    public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
        if (ids == null || ids.isEmpty()) return List.of();
        return documentRepository.findAllById(ids).stream()
                .map(d -> new DocumentBatchSummary(
                        d.getId(),
                        d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
                        "/api/documents/" + d.getId() + "/file"))
                .toList();
    }

    /**
     * Applies a bulk-edit DTO to a single document atomically.
     * Tags and receivers are additive (merged into existing sets); sender and the
     * three location fields are replace-on-non-blank (null/blank means "no change").
     * Wrapped in its own transaction so a failure on one document never partially
     * mutates another in the controller's batch loop.
     *
     * Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
     * event tagged {@code source=BULK_EDIT} and writes a row to
     * {@code document_versions} so the family archive's "who changed what"
     * trail stays complete across both single- and bulk-doc edit paths.
     *
     * NOTE on N+1: tag and person resolution happens per-document. With 500
     * documents × 10 tags this fans out to ~5000 tag-resolve queries per
     * request. Acceptable today because the family archive is bounded at
     * ~1500 documents total. Tracked as a perf follow-up.
     */
    @Transactional
    public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
        Document doc = documentRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));

        if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
            Set<Tag> merged = new HashSet<>(doc.getTags());
            merged.addAll(resolveTags(dto.getTagNames()));
            doc.setTags(merged);
        }

        if (dto.getSenderId() != null) {
            doc.setSender(personService.getById(dto.getSenderId()));
        }

        if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
            Set<Person> merged = new HashSet<>(doc.getReceivers());
            merged.addAll(personService.getAllById(dto.getReceiverIds()));
            doc.setReceivers(merged);
        }

        if (StringUtils.hasText(dto.getDocumentLocation())) {
            doc.setDocumentLocation(dto.getDocumentLocation());
        }
        if (StringUtils.hasText(dto.getArchiveBox())) {
            doc.setArchiveBox(dto.getArchiveBox());
        }
        if (StringUtils.hasText(dto.getArchiveFolder())) {
            doc.setArchiveFolder(dto.getArchiveFolder());
        }

        Document saved = documentRepository.save(doc);
        documentVersionService.recordVersion(saved);
        auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
                Map.of("source", "BULK_EDIT"));
        return saved;
    }

    /**
     * Hilfsmethode: Erstellt Platzhalter (wird später vom Excel-Service genutzt)
     */
    @Transactional
    public Document createPlaceholder(String filenameFromExcel) {
        if (documentRepository.existsByOriginalFilename(filenameFromExcel)) {
            log.info("Platzhalter für {} existiert schon, überspringe.", filenameFromExcel);
            return documentRepository.findByOriginalFilename(filenameFromExcel).get();
        }

        Document doc = Document.builder()
                .originalFilename(filenameFromExcel)
                .title(filenameFromExcel)
                .status(DocumentStatus.PLACEHOLDER)
                .build();

        return documentRepository.save(doc);
    }

    @Transactional
    public Document attachFile(UUID id, MultipartFile file, UUID actorId) {
        Document doc = documentRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
        FileService.UploadResult upload;
        try {
            upload = fileService.uploadFile(file, file.getOriginalFilename());
        } catch (IOException e) {
            throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
        }
        doc.setFilePath(upload.s3Key());
        doc.setFileHash(upload.fileHash());
        doc.setOriginalFilename(file.getOriginalFilename());
        doc.setContentType(file.getContentType());
        boolean wasPlaceholder = doc.getStatus() == DocumentStatus.PLACEHOLDER;
        if (wasPlaceholder) {
            doc.setStatus(DocumentStatus.UPLOADED);
        }
        Document saved = documentRepository.save(doc);
        documentVersionService.recordVersion(saved);
        thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
        if (wasPlaceholder) {
            auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
        }
        return saved;
    }

    @Transactional(readOnly = true)
    public List<Document> getRecentActivity(int size) {
        return documentRepository.findAll(
                PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
        ).getContent();
    }

    // 1. Allgemeine Suche (für das Suchfeld im Frontend)
    public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
        boolean hasText = StringUtils.hasText(filters.text());

        // Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
        // findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
        // findAllMatchingIdsByFts call so the fast path is preserved. An active undated
        // filter must NOT take this path: it bypasses buildSearchSpec, so the
        // undatedOnly predicate would be silently dropped. By definition this path has
        // no date/sender/receiver/tag/status filters, and undated documents are valid
        // FTS hits already folded into the ranked page, so there is no separate undated
        // count to report here.
        if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
            return relevanceSortedPageFromSql(filters.text(), pageable);
        }

        List<UUID> rankedIds = null;
        if (hasText) {
            rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
            // FTS matched nothing → no results and, by definition, no undated matches either.
            if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
        }

        // Global undated count for the current filter (q/tags/sender/receiver/status),
        // forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
        // it never collapses to the page slice and never double-counts (issue #668).
        long undatedCount = countUndatedForFilter(hasText, rankedIds, filters.withUndated(true));

        return runSearch(hasText, rankedIds, filters, sort, dir, pageable)
                .withUndatedCount(undatedCount);
    }

    /**
     * Counts every undated document (meta_date IS NULL) matching the active filter,
     * across all pages, independent of the undated toggle. The caller passes
     * {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
     * regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
     * excludes undated rows by the collision rule (#668), so the count is legitimately 0
     * inside a date range.
     */
    private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
        Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
        return documentRepository.count(undatedSpec);
    }

    /** The original search dispatch — produces the page slice + totals, sans undated count. */
    private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
                                           DocumentSort sort, String dir, Pageable pageable) {
        // The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
        // before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
        Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
        String text = filters.text();

        // SENDER and RECEIVER sorts load the full match set and slice in-memory.
        // JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
        // documents with null sender/receivers. Cost scales with match count —
        // acceptable while documents stays under ~10k rows. (ADR-008)
        if (sort == DocumentSort.RECEIVER) {
            // In-memory sort on page slice (≤ page size rows) — acceptable
            List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
            return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
        }
        if (sort == DocumentSort.SENDER) {
            // In-memory sort on page slice (≤ page size rows) — acceptable
            List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
            return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
        }

        // RELEVANCE with active filters: load filtered subset and sort in-memory by rank.
        boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
        if (useRankOrder) {
            Map<UUID, Integer> rankMap = new HashMap<>();
            for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
            List<Document> sorted = documentRepository.findAll(spec).stream()
                    .sorted(Comparator.comparingInt(doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
                    .toList();
            return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
        }

        // Fast path — push sort + paging into the DB and enrich only the returned slice.
        PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
        Page<Document> page = documentRepository.findAll(spec, pageRequest);
        return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
    }

    private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
        return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
                && filters.from() == null && filters.to() == null
                && filters.sender() == null && filters.receiver() == null
                && (filters.tags() == null || filters.tags().isEmpty())
                && (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
    }

    /**
     * Pure-text RELEVANCE path — pagination and ts_rank ordering pushed into SQL.
     * Called when no non-text filters are active (ADR-008).
     */
    private DocumentSearchResult relevanceSortedPageFromSql(String text, Pageable pageable) {
        long rawOffset = pageable.getOffset();
        if (rawOffset > Integer.MAX_VALUE) return DocumentSearchResult.of(List.of());
        int offset = (int) rawOffset;
        int limit = pageable.getPageSize();
        FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
        if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());

        // Preserve ts_rank order from SQL across the JPA findByIdIn call.
        Map<UUID, Integer> rankMap = new HashMap<>();
        List<UUID> pageIds = new ArrayList<>();
        for (int i = 0; i < ftsPage.hits().size(); i++) {
            rankMap.put(ftsPage.hits().get(i).id(), i);
            pageIds.add(ftsPage.hits().get(i).id());
        }
        List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
                .sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
                .toList();
        return buildResultPaged(docs, text, pageable, ftsPage.total());
    }

    private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
        int from = Math.min((int) pageable.getOffset(), sorted.size());
        int to = Math.min(from + pageable.getPageSize(), sorted.size());
        return sorted.subList(from, to);
    }

    private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
        return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
    }

    private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
        List<Document> colorResolved = resolveDocumentTagColors(documents);
        Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);

        List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
        Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
        Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);

        return colorResolved.stream().map(doc -> toListItem(
                doc,
                matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
                completionByDoc.getOrDefault(doc.getId(), 0),
                contributorsByDoc.getOrDefault(doc.getId(), List.of())
        )).toList();
    }

    private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
        return new DocumentListItem(
                doc.getId(),
                doc.getTitle(),
                doc.getOriginalFilename(),
                doc.getThumbnailUrl(),
                doc.getDocumentDate(),
                doc.getMetaDatePrecision(),
                doc.getMetaDateEnd(),
                doc.getSender(),
                List.copyOf(doc.getReceivers()),
                List.copyOf(doc.getTags()),
                doc.getArchiveBox(),
                doc.getArchiveFolder(),
                doc.getLocation(),
                doc.getSummary(),
                completionPct,
                contributors,
                match,
                doc.getCreatedAt(),
                doc.getUpdatedAt()
        );
    }

    private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
        return transcriptionBlockQueryService.getCompletionStats(docIds);
    }

    private Sort resolveSort(DocumentSort sort, String dir) {
        Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
        if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) {
            // Undated documents (null documentDate) must order last regardless of
            // direction — Postgres puts NULLs FIRST on ASC by default, which would
            // surface the undated pile at the top with no explanation (issue #668).
            // The title tiebreaker gives a stable total order when every row is
            // null-dated (the "Nur undatierte" filter), so pagination is deterministic.
            // title is @Column(nullable=false), so it is always present.
            return Sort.by(
                    new Sort.Order(direction, "documentDate").nullsLast(),
                    Sort.Order.asc("title"));
        }
        // SENDER and RECEIVER are sorted in-memory before this method is called
        return switch (sort) {
            case TITLE -> Sort.by(direction, "title");
            case UPLOAD_DATE -> Sort.by(direction, "createdAt");
            case UPDATED_AT -> Sort.by(direction, "updatedAt");
            default -> Sort.by(direction, "documentDate");
        };
    }

    private List<Document> sortBySender(List<Document> documents, String dir) {
        boolean ascending = "ASC".equalsIgnoreCase(dir);
        Comparator<String> nullSafeComparator = (a, b) -> {
            if (a.isEmpty() && b.isEmpty()) return 0;
            if (a.isEmpty()) return ascending ? 1 : -1;
            if (b.isEmpty()) return ascending ? -1 : 1;
            return ascending ? a.compareTo(b) : b.compareTo(a);
        };
        return documents.stream()
                .sorted(Comparator.comparing(doc -> {
                    Person s = doc.getSender();
                    if (s == null || s.getLastName() == null) return "";
                    return s.getLastName() + " " + Objects.toString(s.getFirstName(), "");
                }, nullSafeComparator))
                .toList();
    }

    private List<Document> sortByFirstReceiver(List<Document> documents, String dir) {
        boolean ascending = "ASC".equalsIgnoreCase(dir);
        Comparator<String> nullSafeComparator = (a, b) -> {
            if (a.isEmpty() && b.isEmpty()) return 0;
            if (a.isEmpty()) return 1;
            if (b.isEmpty()) return -1;
            return ascending ? a.compareTo(b) : b.compareTo(a);
        };
        return documents.stream()
                .sorted(Comparator.comparing(this::firstReceiverSortKey, nullSafeComparator))
                .toList();
    }

    private String firstReceiverSortKey(Document doc) {
        return doc.getReceivers().stream()
                .min(Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName))
                .map(p -> p.getLastName() + " " + p.getFirstName())
                .orElse("");
    }

    @Transactional
    public void updateScriptType(UUID documentId, ScriptType scriptType) {
        Document doc = getDocumentById(documentId);
        doc.setScriptType(scriptType);
        documentRepository.save(doc);
    }

    @Transactional
    public void addTrainingLabel(UUID documentId, TrainingLabel label) {
        Document doc = getDocumentById(documentId);
        doc.getTrainingLabels().add(label);
        documentRepository.save(doc);
    }

    @Transactional
    public void removeTrainingLabel(UUID documentId, TrainingLabel label) {
        Document doc = getDocumentById(documentId);
        doc.getTrainingLabels().remove(label);
        documentRepository.save(doc);
    }

    @Transactional(readOnly = true)
    public Document getDocumentById(UUID id) {
        Document doc = documentRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
        tagService.resolveEffectiveColors(doc.getTags());
        return doc;
    }

    /**
     * Lightweight summary lookup for internal use (e.g. journey item append validation).
     *
     * <p><strong>Security contract — read before calling:</strong>
     * <ol>
     *   <li>This method intentionally bypasses per-document scope checks and
     *       tag-colour resolution. It must only be invoked after
     *       {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
     *       the controller layer, guaranteeing the caller is an authenticated
     *       author.</li>
     *   <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
     *       JOURNEY-type check that fires before this call — so the method is never
     *       reached for STORY-type Geschichten.</li>
     * </ol>
     * Under the current single-tenant model every authenticated author shares the
     * same document scope, so skipping per-document scope checks is safe.
     */
    public Document findSummaryByIdInternal(UUID id) {
        return documentRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
    }

    /**
     * Loads a document for the detail view, additionally flagging whether it has any
     * transcription to read. Kept separate from {@link #getDocumentById} so the cheap
     * existence query only runs for the single-document detail endpoint, not for the
     * many internal callers that never read the flag.
     */
    @Transactional(readOnly = true)
    public Document getDocumentDetail(UUID id) {
        Document doc = getDocumentById(id);
        doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
        return doc;
    }

    public List<Document> getDocumentsByIds(List<UUID> ids) {
        return documentRepository.findAllById(ids);
    }

    public List<Document> getDocumentsWithoutVersions() {
        return documentRepository.findDocumentsWithoutVersions();
    }

    public List<Document> getAllForTimeline() {
        return documentRepository.findAllForTimeline();
    }

    public List<Document> getDocumentsBySender(UUID senderId) {
        return documentRepository.findBySenderId(senderId);
    }

    public List<Document> getDocumentsByReceiver(UUID receiverId) {
        return documentRepository.findByReceiversId(receiverId);
    }

    public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
        Person person = personService.getById(personId);
        Specification<Document> spec = buildPersonSpec(person, from, to);
        Page<Document> page = documentRepository.findAll(spec, pageable);
        List<DocumentListItem> items = enrichItems(page.getContent(), null);
        return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
    }

    private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
        return (root, query, cb) -> {
            if (query != null) query.distinct(true);
            var receiversJoin = root.join("receivers", JoinType.LEFT);
            var senderPredicate = cb.equal(root.get("sender"), person);
            var receiverPredicate = cb.equal(receiversJoin, person);
            var personPredicate = cb.or(senderPredicate, receiverPredicate);
            var predicates = new ArrayList<>(List.of(personPredicate));
            if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
            if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }

    public long getIncompleteCount() {
        return documentRepository.countByMetadataCompleteFalse();
    }

    public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
        PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
        return documentRepository.findByMetadataCompleteFalse(pageable)
                .stream()
                .map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
                .toList();
    }

    public Optional<Document> findNextIncompleteDocument(UUID currentId) {
        return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
                currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
    }

    @Transactional
    public void deleteDocument(UUID id, UUID actorId) {
        if (!documentRepository.existsById(id)) {
            throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
        }
        eventPublisher.publishEvent(new DocumentDeletingEvent(id));
        documentRepository.deleteById(id);
        auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
    }

    @Transactional
    public void deleteTagCascading(UUID tagId) {
        documentRepository.findByTags_Id(tagId).forEach(doc -> {
            doc.getTags().removeIf(t -> t.getId().equals(tagId));
            documentRepository.save(doc);
        });
        tagService.delete(tagId);
    }

    /**
     * One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
     * stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
     * the title from the row's current state and persists it only when it actually changed.
     * Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
     * left untouched.
     *
     * <p>Saves via {@code documentRepository.save} directly — it must NOT route through
     * {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
     * precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
     *
     * @return the number of documents whose title was rewritten
     */
    @Transactional
    public int backfillTitles() {
        List<Document> docs = documentRepository.findAll();
        int updated = 0;
        int skipped = 0;
        for (Document doc : docs) {
            if (!DocumentTitleBackfillMatcher.isOverwritable(
                    doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
                skipped++;
                continue;
            }
            String rebuilt = documentTitleFactory.build(doc);
            if (rebuilt.equals(doc.getTitle())) {
                skipped++; // already correct — keep idempotent, no write
                continue;
            }
            doc.setTitle(rebuilt);
            documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
            updated++;
        }
        log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
        return updated;
    }

    @Transactional
    public int backfillFileHashes() {
        List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
        int count = 0;
        for (Document doc : docs) {
            try {
                byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
                String hash = sha256Hex(bytes);
                doc.setFileHash(hash);
                documentRepository.save(doc);
                annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
                count++;
            } catch (Exception e) {
                log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
            }
        }
        return count;
    }

    // ─── private helpers ──────────────────────────────────────────────────────

    private List<Document> resolveDocumentTagColors(List<Document> docs) {
        List<Tag> allTags = docs.stream().flatMap(d -> d.getTags().stream()).toList();
        tagService.resolveEffectiveColors(allTags);
        return docs;
    }

    private static String stripExtension(String filename) {
        if (filename == null) return null;
        int dot = filename.lastIndexOf('.');
        return dot > 0 ? filename.substring(0, dot) : filename;
    }

    private record ParsedFilename(LocalDate date, String firstName, String lastName) {
        String title() {
            String dateDisplay = String.format("%02d.%02d.%d",
                    date.getDayOfMonth(), date.getMonthValue(), date.getYear());
            return firstName + " " + lastName + " (" + dateDisplay + ")";
        }
    }

    /**
     * Parses a structured filename into its date and name components.
     *
     * Algorithm: split stem on "_", identify the date token (first or last segment),
     * treat the outermost remaining segment as firstName, rest as lastName parts.
     * Compound last names (e.g. "de_Gruyter") are supported naturally.
     * Returns null for unrecognised filenames.
     *
     * Examples:
     *   18881025_de_Gruyter_Walter.pdf  → date=1888-10-25, firstName=Walter, lastName=de Gruyter
     *   1965-03-12_Mueller_Hans.pdf     → date=1965-03-12, firstName=Hans,   lastName=Mueller
     *   Mueller_Hans_19650312.pdf       → date=1965-03-12, firstName=Hans,   lastName=Mueller
     */
    private static ParsedFilename parseFilenameData(String filename) {
        if (filename == null) return null;
        int dot = filename.lastIndexOf('.');
        if (dot < 0) return null;
        String stem = filename.substring(0, dot);

        String[] parts = stem.split("_", -1);
        if (parts.length < 3) return null;

        String dateIso;
        String[] nameParts;

        String dateFromFirst = tryParseDate(parts[0]);
        if (dateFromFirst != null) {
            dateIso = dateFromFirst;
            nameParts = Arrays.copyOfRange(parts, 1, parts.length);
        } else {
            String dateFromLast = tryParseDate(parts[parts.length - 1]);
            if (dateFromLast == null) return null;
            dateIso = dateFromLast;
            nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
        }

        if (nameParts.length < 2) return null;
        for (String p : nameParts) {
            if (!p.matches("\\p{L}+")) return null;
        }

        String firstName = nameParts[nameParts.length - 1];
        String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
        return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
    }

    // Used by tests and as a public utility; delegates to parseFilenameData.
    static String titleFromFilename(String filename) {
        if (filename == null) return null;
        ParsedFilename parsed = parseFilenameData(filename);
        return parsed != null ? parsed.title() : stripExtension(filename);
    }

    private static String tryParseDate(String s) {
        if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
            int m = Integer.parseInt(s.substring(5, 7));
            int d = Integer.parseInt(s.substring(8, 10));
            if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
        } else if (s.matches("\\d{8}")) {
            int m = Integer.parseInt(s.substring(4, 6));
            int d = Integer.parseInt(s.substring(6, 8));
            if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
                return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
        }
        return null;
    }

    /**
     * Calls {@code findEnrichmentData} and converts the raw Object[] rows into a
     * {@link SearchMatchData} per document. Short-circuits when the list is empty or
     * the query is blank (no text search active).
     */
    private Map<UUID, SearchMatchData> enrichWithMatchData(List<Document> docs, String query) {
        if (docs.isEmpty() || !StringUtils.hasText(query)) return Map.of();
        List<UUID> ids = docs.stream().map(Document::getId).toList();
        Map<UUID, SearchMatchData> result = new HashMap<>();
        for (Object[] row : documentRepository.findEnrichmentData(ids, query)) {
            UUID docId = (UUID) row[0];
            String titleHeadline = (String) row[1];
            String snippetHeadline = (String) row[2];
            Boolean senderMatched = (Boolean) row[3];
            String receiverIdsStr = (String) row[4];
            String tagIdsStr = (String) row[5];
            String summaryHeadline = (String) row[6];
            ParsedHighlight snippet = parseHighlight(snippetHeadline);
            ParsedHighlight summary = parseHighlight(summaryHeadline);
            result.put(docId, new SearchMatchData(
                    snippet != null ? snippet.cleanText() : null,
                    parseTitleOffsets(titleHeadline),
                    senderMatched != null && senderMatched,
                    parseUUIDs(receiverIdsStr),
                    parseUUIDs(tagIdsStr),
                    snippet != null ? snippet.offsets() : List.of(),
                    summary != null ? summary.cleanText() : null,
                    summary != null ? summary.offsets() : List.of()
            ));
        }
        return result;
    }

    private static final int COL_ID = 0;
    private static final int COL_RANK = 1;
    private static final int COL_TOTAL = 2;

    /**
     * Maps raw Object[] rows from {@link DocumentRepository#findFtsPageRaw} to an
     * {@link FtsPage}. Uses pattern-matching UUID cast to guard against driver-level
     * type variance (some JDBC drivers return UUID as String).
     */
    private static FtsPage toFtsPage(List<Object[]> rows) {
        if (rows.isEmpty()) return new FtsPage(List.of(), 0);
        long total = ((Number) rows.get(0)[COL_TOTAL]).longValue();
        List<FtsHit> hits = rows.stream()
                .map(r -> {
                    UUID id = r[COL_ID] instanceof UUID u ? u : UUID.fromString(r[COL_ID].toString());
                    double rank = ((Number) r[COL_RANK]).doubleValue();
                    return new FtsHit(id, rank);
                })
                .toList();
        return new FtsPage(hits, total);
    }

    /** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
    public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}

    /**
     * Parses a {@code ts_headline} result that uses {@code chr(1)}/{@code chr(2)} as
     * start/stop delimiters. Returns the clean text (delimiters stripped) together with
     * the character offsets of each highlighted span. Returns {@code null} when
     * {@code headline} is {@code null}.
     */
    public static ParsedHighlight parseHighlight(String headline) {
        if (headline == null) return null;
        StringBuilder clean = new StringBuilder(headline.length());
        List<MatchOffset> offsets = new ArrayList<>();
        int i = 0;
        int pos = 0; // position in the clean string (no delimiters)
        while (i < headline.length()) {
            char c = headline.charAt(i);
            if (c == '\u0001') {
                int start = pos;
                i++;
                while (i < headline.length() && headline.charAt(i) != '\u0002') {
                    clean.append(headline.charAt(i));
                    i++;
                    pos++;
                }
                offsets.add(new MatchOffset(start, pos - start));
                i++; // skip \u0002
            } else {
                clean.append(c);
                i++;
                pos++;
            }
        }
        return new ParsedHighlight(clean.toString(), offsets);
    }

    /**
     * Extracts only the {@link MatchOffset} list from a title headline.
     * The clean title text comes from the {@link Document} entity itself.
     */
    private static List<MatchOffset> parseTitleOffsets(String headline) {
        ParsedHighlight parsed = parseHighlight(headline);
        return parsed != null ? parsed.offsets() : List.of();
    }

    private static List<UUID> parseUUIDs(String csv) {
        if (csv == null || csv.isBlank()) return List.of();
        return Arrays.stream(csv.split(","))
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .map(UUID::fromString)
                .toList();
    }

    private static String sha256Hex(byte[] bytes) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(bytes);
            StringBuilder sb = new StringBuilder(64);
            for (byte b : hash) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }

}
