package org.raddatz.familienarchiv.document;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;

import java.time.LocalDate;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;

/**
 * End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
 * Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
 * UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
 * against the actual JPA query.
 *
 * <p>Closes the integration-coverage gap Sara flagged on PR #316.
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class DocumentSearchPagedIntegrationTest {

    private static final int FIXTURE_SIZE = 120;

    @MockitoBean S3Client s3Client;
    @Autowired DocumentService documentService;
    @Autowired DocumentRepository documentRepository;

    @BeforeEach
    void seed() {
        // Deterministic date spread so DATE-DESC order is predictable:
        // document #0 has the oldest date, document #119 has the newest.
        for (int i = 0; i < FIXTURE_SIZE; i++) {
            Document doc = Document.builder()
                    .title("Dok-" + String.format("%03d", i))
                    .originalFilename("dok-" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
                    .build();
            documentRepository.save(doc);
        }
        assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
    }

    @Test
    void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));

        assertThat(result.items()).hasSize(50);
        assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
        assertThat(result.pageNumber()).isZero();
        assertThat(result.pageSize()).isEqualTo(50);
        assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
    }

    @Test
    void search_lastPartialPage_returnsRemainingItems() {
        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(2, 50));

        // Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
        assertThat(result.items()).hasSize(20);
        assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
        assertThat(result.pageNumber()).isEqualTo(2);
    }

    @Test
    void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(99, 50));

        assertThat(result.items()).isEmpty();
        assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
    }

    @Test
    void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
        // SENDER sort path fetches all + sorts + slices in-memory (see scaling
        // comment in DocumentService). Proves that the in-memory slice path
        // returns the correct total from a real repository fetch.
        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.SENDER, "asc", PageRequest.of(1, 50));

        assertThat(result.items()).hasSize(50);
        assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
        assertThat(result.pageNumber()).isEqualTo(1);
        assertThat(result.totalPages()).isEqualTo(3);
    }

    @Test
    void search_undatedCount_isGlobalFilteredTotal_notPageSlice() {
        // Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page
        // window the undated rows span multiple pages, so a page-local count could
        // never exceed 50 — the global count must be the full 70 (issue #668).
        int undatedTotal = 70;
        for (int i = 0; i < undatedTotal; i++) {
            documentRepository.save(Document.builder()
                    .title("Undatiert-" + String.format("%03d", i))
                    .originalFilename("undatiert-" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .metaDatePrecision(DatePrecision.UNKNOWN)
                    .documentDate(null)
                    .build());
        }

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));

        // Global undated count is the full undated total, independent of page size.
        assertThat(result.undatedCount()).isEqualTo(undatedTotal);
        // Total matches both dated + undated (no undated-only filter applied).
        assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal);
        // The first DATE-DESC page is all dated rows (nulls last), so a page-local
        // tally would report 0 undated — proving the count is not page-derived.
        assertThat(result.items()).allMatch(item -> item.documentDate() != null);
    }

    @Test
    void search_undatedCount_ignoresUndatedOnlyToggle() {
        // The "Nur undatierte" toggle must not skew the count: whether undated=true or
        // false, the global undated count for the same filter is identical (issue #668).
        int undatedTotal = 12;
        for (int i = 0; i < undatedTotal; i++) {
            documentRepository.save(Document.builder()
                    .title("U-" + i)
                    .originalFilename("u-" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .metaDatePrecision(DatePrecision.UNKNOWN)
                    .documentDate(null)
                    .build());
        }

        DocumentSearchResult unfiltered = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
        DocumentSearchResult undatedOnly = documentService.searchDocuments(
                noFilters().withUndated(true),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));

        assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
        assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
    }

    @Test
    void search_undatedCount_isZero_insideDateRange() {
        // A from/to range excludes undated rows by the collision rule (#668), so the
        // global undated count inside a range is legitimately 0 even when undated docs exist.
        for (int i = 0; i < 5; i++) {
            documentRepository.save(Document.builder()
                    .title("U-range-" + i)
                    .originalFilename("u-range-" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .metaDatePrecision(DatePrecision.UNKNOWN)
                    .documentDate(null)
                    .build());
        }

        DocumentSearchResult result = documentService.searchDocuments(
                new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
                        null, null, null, null, null, null, false),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));

        assertThat(result.undatedCount()).isZero();
    }

    @Test
    void search_differentPagesReturnDisjointSlices() {
        DocumentSearchResult page0 = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
        DocumentSearchResult page1 = documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", PageRequest.of(1, 50));

        // No document id should appear on both pages — slicing must be exclusive.
        var idsOnPage0 = page0.items().stream()
                .map(item -> item.id())
                .toList();
        var idsOnPage1 = page1.items().stream()
                .map(item -> item.id())
                .toList();
        for (UUID id : idsOnPage0) {
            assertThat(idsOnPage1).doesNotContain(id);
        }
    }
}
