package org.raddatz.familienarchiv.geschichte.journeyitem;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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.util.List;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class JourneyItemIntegrationTest {

    @MockitoBean
    S3Client s3Client;

    @MockitoBean
    AuditService auditService;

    @PersistenceContext
    EntityManager em;

    @Autowired GeschichteRepository geschichteRepository;
    @Autowired JourneyItemRepository journeyItemRepository;
    @Autowired JourneyItemService journeyItemService;
    @Autowired DocumentService documentService;
    @Autowired DocumentRepository documentRepository;
    @Autowired AppUserRepository appUserRepository;

    Geschichte journey;
    Document doc;
    AppUser writer;

    @BeforeEach
    void seed() {
        writer = appUserRepository.save(AppUser.builder()
                .email("journey-writer@test")
                .password("hash")
                .build());
        doc = documentRepository.save(Document.builder()
                .title("Testbrief")
                .originalFilename("testbrief.pdf")
                .status(DocumentStatus.UPLOADED)
                .build());
        journey = geschichteRepository.save(Geschichte.builder()
                .title("Eine Lesereise")
                .status(GeschichteStatus.DRAFT)
                .type(GeschichteType.JOURNEY)
                .build());
        em.flush();
        em.clear();
    }

    @AfterEach
    void clearSecurity() {
        SecurityContextHolder.clearContext();
    }

    private void authenticateAs(AppUser user, Permission... permissions) {
        var authorities = java.util.Arrays.stream(permissions)
                .map(p -> new SimpleGrantedAuthority(p.name()))
                .toList();
        SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
    }

    // ─── @OrderBy ─────────────────────────────────────────────────────────────

    @Test
    void items_are_returned_in_position_order_regardless_of_insertion_order() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();

        // Distinct content per item — V74's partial unique index forbids the same
        // document twice in one journey, and ordering doesn't depend on it.
        JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
        JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
        JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
        managed.getItems().addAll(List.of(third, first, second));
        geschichteRepository.save(managed);
        em.flush();
        em.clear();

        Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
        List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();

        assertThat(positions).containsExactly(1000, 2000, 3000);
    }

    // ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────

    @Test
    void deleting_geschichte_cascade_deletes_all_journey_items() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
        managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
        geschichteRepository.save(managed);
        em.flush();
        em.clear();

        UUID geschichteId = journey.getId();
        geschichteRepository.deleteById(geschichteId);
        em.flush();

        assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
    }

    @Test
    void removing_item_from_items_list_triggers_orphan_removal() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
        managed.getItems().add(item);
        Geschichte saved = geschichteRepository.save(managed);
        em.flush();
        UUID itemId = saved.getItems().get(0).getId(); // extract before clear
        em.clear();
        Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
        reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
        geschichteRepository.save(reloaded);
        em.flush();

        assertThat(journeyItemRepository.findById(itemId)).isEmpty();
    }

    // ─── GeschichteType round-trip ────────────────────────────────────────────

    @Test
    void type_persists_as_JOURNEY_and_roundtrips() {
        Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
        assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
    }

    @Test
    void type_defaults_to_STORY_for_new_geschichten() {
        Geschichte story = geschichteRepository.save(Geschichte.builder()
                .title("Erinnerung")
                .status(GeschichteStatus.DRAFT)
                .build());
        em.flush();
        em.clear();

        Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
        assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
    }

    // ─── Note-only item (document_id IS NULL) ─────────────────────────────────

    @Test
    void note_only_item_persists_without_document() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        JourneyItem note = JourneyItem.builder()
                .geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
        managed.getItems().add(note);
        Geschichte saved = geschichteRepository.save(managed);
        em.flush();
        UUID noteId = saved.getItems().get(0).getId(); // extract before clear
        em.clear();
        JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
        assertThat(reloaded.getDocumentId()).isNull();
        assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
    }

    // ─── Document-backed item exposes documentId ──────────────────────────────

    @Test
    void document_backed_item_exposes_document_uuid_via_getDocumentId() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        JourneyItem item = JourneyItem.builder()
                .geschichte(managed).position(1000).document(doc).build();
        managed.getItems().add(item);
        Geschichte saved = geschichteRepository.save(managed);
        em.flush();
        UUID itemId = saved.getItems().get(0).getId(); // extract before clear
        em.clear();
        JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
        assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
    }

    // ─── ON DELETE SET NULL ───────────────────────────────────────────────────

    @Test
    void deleting_document_sets_item_document_to_null_not_delete_item() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        JourneyItem item = JourneyItem.builder()
                .geschichte(managed).position(1000).document(doc).note("still here").build();
        managed.getItems().add(item);
        Geschichte saved = geschichteRepository.save(managed);
        em.flush();
        UUID itemId = saved.getItems().get(0).getId(); // extract before clear
        em.clear();

        // Route through service so the DocumentDeletingEvent fires and the listener
        // removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
        documentService.deleteDocument(doc.getId(), writer.getId());
        em.flush();
        em.clear();

        JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
        assertThat(surviving.getDocumentId()).isNull();
        assertThat(surviving.getNote()).isEqualTo("still here");
    }

    // ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────

    @Test
    void saving_item_with_neither_document_nor_note_violates_check_constraint() {
        Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
        JourneyItem empty = JourneyItem.builder()
                .geschichte(managed).position(1000).build();

        assertThatThrownBy(() -> {
            journeyItemRepository.save(empty);
            journeyItemRepository.flush();
        }).isInstanceOf(Exception.class);
    }

    // ─── JourneyItemService.append — end-to-end persistence ──────────────────

    @Test
    void append_persists_item_at_position_10() {
        // Arrange: authenticate as a user with BLOG_WRITE
        authenticateAs(writer, Permission.BLOG_WRITE);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("First stop");

        // Act
        JourneyItemView view = journeyItemService.append(journey.getId(), dto);
        em.flush();
        em.clear();

        // Assert: item exists in DB at position 10
        assertThat(view.position()).isEqualTo(10);
        assertThat(view.note()).isEqualTo("First stop");
        List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
        assertThat(persisted).hasSize(1);
        assertThat(persisted.get(0).getPosition()).isEqualTo(10);
        assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
    }

    @Test
    void append_document_persists_and_rejects_duplicate() {
        // Covers the document branch of append, including the duplicate guard —
        // the derived exists query must resolve document.id, which the transient
        // getDocumentId() getter on JourneyItem shadows for Spring Data.
        authenticateAs(writer, Permission.BLOG_WRITE);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(doc.getId());

        JourneyItemView view = journeyItemService.append(journey.getId(), dto);
        em.flush();
        em.clear();

        assertThat(view.document()).isNotNull();
        assertThat(view.document().id()).isEqualTo(doc.getId());

        JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
        duplicate.setDocumentId(doc.getId());
        assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
                .hasFieldOrPropertyWithValue("code",
                        org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
    }

    // ─── STORY-type Geschichten hold journey items (#795) ────────────────────

    @Test
    void story_type_can_hold_journey_items_through_service() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        Geschichte story = savedStory();

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(doc.getId());
        JourneyItemView appended = journeyItemService.append(story.getId(), dto);
        em.flush();
        em.clear();

        List<JourneyItemView> items = journeyItemService.getItems(story.getId());
        assertThat(items).hasSize(1);
        assertThat(items.get(0).id()).isEqualTo(appended.id());
        assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
    }

    @Test
    void v72_migrated_story_items_keep_position_order_and_are_removable() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        Geschichte story = savedStory();
        Document docB = documentRepository.save(Document.builder()
                .title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
        Document docC = documentRepository.save(Document.builder()
                .title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());

        // V72 inserted journey_items rows directly with position gaps — mirror that
        // by writing through the repository instead of the service.
        JourneyItem first = journeyItemRepository.save(
                JourneyItem.builder().geschichte(story).position(10).document(doc).build());
        JourneyItem second = journeyItemRepository.save(
                JourneyItem.builder().geschichte(story).position(20).document(docB).build());
        JourneyItem third = journeyItemRepository.save(
                JourneyItem.builder().geschichte(story).position(30).document(docC).build());
        em.flush();
        em.clear();

        assertThat(journeyItemService.getItems(story.getId()))
                .extracting(JourneyItemView::position)
                .containsExactly(10, 20, 30);

        journeyItemService.delete(story.getId(), second.getId());
        em.flush();
        em.clear();

        assertThat(journeyItemService.getItems(story.getId()))
                .extracting(JourneyItemView::id)
                .containsExactly(first.getId(), third.getId());
    }

    @Test
    void story_item_with_deleted_document_survives_and_remains_deletable() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        Geschichte story = savedStory();

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(doc.getId());
        // The note keeps chk_journey_item_not_empty satisfied once ON DELETE
        // SET NULL clears document_id — a note-less item would block the
        // document delete at the DB instead.
        dto.setNote("Begleittext");
        JourneyItemView appended = journeyItemService.append(story.getId(), dto);
        em.flush();
        em.clear();

        // Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
        documentService.deleteDocument(doc.getId(), writer.getId());
        em.flush();
        em.clear();

        List<JourneyItemView> items = journeyItemService.getItems(story.getId());
        assertThat(items).hasSize(1);
        assertThat(items.get(0).document()).isNull();

        journeyItemService.delete(story.getId(), appended.id());
        em.flush();
        em.clear();

        assertThat(journeyItemService.getItems(story.getId())).isEmpty();
    }

    private Geschichte savedStory() {
        return geschichteRepository.save(Geschichte.builder()
                .title("Eine Geschichte")
                .status(GeschichteStatus.DRAFT)
                .type(GeschichteType.STORY)
                .build());
    }

    // ─── JourneyItemService.reorder — atomicity check ────────────────────────

    @Test
    void reorder_swaps_positions_atomically() {
        // Arrange: append two items (pos 10, pos 20)
        authenticateAs(writer, Permission.BLOG_WRITE);

        JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
        dto1.setNote("Item one");
        JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);

        JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
        dto2.setNote("Item two");
        JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);

        assertThat(item1View.position()).isEqualTo(10);
        assertThat(item2View.position()).isEqualTo(20);

        // Act: reorder with [item2, item1]
        JourneyReorderDTO reorderDto = new JourneyReorderDTO();
        reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
        List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
        em.flush();
        em.clear();

        // Assert: item2 is now at position 10, item1 is at position 20
        List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
        assertThat(persisted).hasSize(2);
        assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
        assertThat(persisted.get(0).getPosition()).isEqualTo(10);
        assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
        assertThat(persisted.get(1).getPosition()).isEqualTo(20);
    }
}
