package org.raddatz.familienarchiv.timeline;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;

import org.springframework.orm.ObjectOptimisticLockingFailureException;

import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class TimelineEventServiceTest {

    @Mock TimelineEventRepository events;
    @Mock PersonService personService;
    @Mock DocumentService documentService;
    @InjectMocks TimelineEventService service;

    private final UUID actor = UUID.randomUUID();
    private final UUID secondEditor = UUID.randomUUID();

    /** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */
    private TimelineEventRequest baseRequest() {
        return new TimelineEventRequest(
                "Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, null, null);
    }

    private Person makePerson(UUID id) {
        return Person.builder().id(id).firstName("Anna").lastName("Müller").build();
    }

    private Document makeDocument(UUID id) {
        return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build();
    }

    /** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */
    private TimelineEvent existingEvent(UUID id, UUID creator) {
        return TimelineEvent.builder()
                .id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1))
                .precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L)
                .build();
    }

    /** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */
    private void stubFlushSetsVersion() {
        when(events.saveAndFlush(any())).thenAnswer(inv -> {
            TimelineEvent e = inv.getArgument(0);
            if (e.getVersion() == null) e.setVersion(0L);
            return e;
        });
    }

    private TimelineEvent captureSaved() {
        ArgumentCaptor<TimelineEvent> captor = ArgumentCaptor.forClass(TimelineEvent.class);
        verify(events).saveAndFlush(captor.capture());
        return captor.getValue();
    }

    // --- create ---

    @Test
    void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() {
        stubFlushSetsVersion();

        TimelineEventView view = service.create(baseRequest(), actor);

        TimelineEvent persisted = captureSaved();
        assertThat(persisted.getCreatedBy()).isEqualTo(actor);
        assertThat(persisted.getUpdatedBy()).isEqualTo(actor);
        assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto");
        assertThat(view.version()).isEqualTo(0L);
    }

    @Test
    void create_defaults_precision_to_YEAR_when_omitted() {
        stubFlushSetsVersion();

        TimelineEventView view = service.create(baseRequest(), actor);

        assertThat(view.precision()).isEqualTo(DatePrecision.YEAR);
    }

    @Test
    void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() {
        stubFlushSetsVersion();
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                DatePrecision.YEAR, null, null, null, null, null);

        TimelineEventView view = service.create(request, actor);

        assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1));
    }

    @Test
    void create_with_null_link_lists_yields_empty_collections_no_npe() {
        stubFlushSetsVersion();

        TimelineEventView view = service.create(baseRequest(), actor);

        assertThat(view.persons()).isEmpty();
        assertThat(view.documents()).isEmpty();
    }

    // --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) ---

    @Test
    void create_rejects_eventDateEnd_when_precision_is_not_RANGE() {
        TimelineEventRequest request = new TimelineEventRequest(
                "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
                DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null);

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void create_rejects_RANGE_with_null_eventDateEnd() {
        TimelineEventRequest request = new TimelineEventRequest(
                "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
                DatePrecision.RANGE, null, null, null, null, null);

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
        TimelineEventRequest request = new TimelineEventRequest(
                "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
                DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
        stubFlushSetsVersion();
        LocalDate sameDay = LocalDate.of(1914, 7, 28);
        TimelineEventRequest request = new TimelineEventRequest(
                "Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
                DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range

        TimelineEventView view = service.create(request, actor);

        assertThat(view.eventDate()).isEqualTo(sameDay);
        assertThat(view.eventDateEnd()).isEqualTo(sameDay);
    }

    // --- title-length service guard ---

    @Test
    void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() {
        String overLong = "x".repeat(256);
        TimelineEventRequest request = new TimelineEventRequest(
                overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, null, null);

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG);
        verify(events, never()).saveAndFlush(any());
    }

    // --- link resolution (fail-closed, dedupe-first) ---

    @Test
    void create_resolves_persons_and_documents() {
        stubFlushSetsVersion();
        UUID personId = UUID.randomUUID();
        UUID docId = UUID.randomUUID();
        when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
        when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, List.of(personId), List.of(docId));

        TimelineEventView view = service.create(request, actor);

        assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId));
        assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId));
    }

    @Test
    void create_with_duplicate_personIds_resolves_to_single_link_not_404() {
        stubFlushSetsVersion();
        UUID personId = UUID.randomUUID();
        when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, List.of(personId, personId), null);

        TimelineEventView view = service.create(request, actor);

        assertThat(view.persons()).hasSize(1);
    }

    @Test
    void create_with_duplicate_documentIds_resolves_to_single_link_not_404() {
        stubFlushSetsVersion();
        UUID docId = UUID.randomUUID();
        when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, null, List.of(docId, docId));

        TimelineEventView view = service.create(request, actor);

        assertThat(view.documents()).hasSize(1);
    }

    @Test
    void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() {
        UUID known = UUID.randomUUID();
        UUID unknown = UUID.randomUUID();
        when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, List.of(known, unknown), null);

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() {
        UUID unknown = UUID.randomUUID();
        when(documentService.getDocumentById(unknown))
                .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown));
        TimelineEventRequest request = new TimelineEventRequest(
                "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, null, List.of(unknown));

        assertThatThrownBy(() -> service.create(request, actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND);
        verify(events, never()).saveAndFlush(any());
    }

    // --- update ---

    @Test
    void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() {
        UUID id = UUID.randomUUID();
        UUID creator = UUID.randomUUID();
        UUID newPersonId = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, creator);
        existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced
        when(events.findById(id)).thenReturn(Optional.of(existing));
        when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
        when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId)));
        TimelineEventRequest request = new TimelineEventRequest(
                "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, List.of(newPersonId), null);

        service.update(id, request, secondEditor);

        assertThat(existing.getCreatedBy()).isEqualTo(creator);
        assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor);
        assertThat(existing.getPersons()).singleElement()
                .satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId));
    }

    @Test
    void update_with_empty_link_lists_clears_all_links() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID());
        existing.getPersons().add(makePerson(UUID.randomUUID()));
        existing.getDocuments().add(makeDocument(UUID.randomUUID()));
        when(events.findById(id)).thenReturn(Optional.of(existing));
        when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
        TimelineEventRequest request = new TimelineEventRequest(
                "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, null, List.of(), List.of());

        service.update(id, request, secondEditor);

        assertThat(existing.getPersons()).isEmpty();
        assertThat(existing.getDocuments()).isEmpty();
    }

    @Test
    void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID());
        when(events.findById(id)).thenReturn(Optional.of(existing));
        TimelineEventRequest request = new TimelineEventRequest(
                "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
                DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start

        assertThatThrownBy(() -> service.update(id, request, secondEditor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
        UUID id = UUID.randomUUID();
        when(events.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.update(id, baseRequest(), actor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
    }

    // --- version / optimistic lock ---

    // Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT
    // event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a
    // managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below
    // is retained as the native backstop for two transactions flushing concurrently.

    @Test
    void update_with_null_version_skips_the_check_and_saves_last_write_wins() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
        when(events.findById(id)).thenReturn(Optional.of(existing));
        when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());

        service.update(id, baseRequest(), secondEditor); // baseRequest has null version

        verify(events).saveAndFlush(existing); // no conflict despite an unknown client base
    }

    @Test
    void update_with_in_sync_version_succeeds_and_saves() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
        when(events.findById(id)).thenReturn(Optional.of(existing));
        when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
        TimelineEventRequest request = new TimelineEventRequest(
                "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, 5L, null, null); // matches the loaded version

        TimelineEventView view = service.update(id, request, secondEditor);

        assertThat(view).isNotNull();
        verify(events).saveAndFlush(existing);
    }

    @Test
    void update_with_stale_version_throws_conflict_without_saving() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
        when(events.findById(id)).thenReturn(Optional.of(existing));
        TimelineEventRequest request = new TimelineEventRequest(
                "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, 2L, null, null); // stale: 2 != 5

        assertThatThrownBy(() -> service.update(id, request, secondEditor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
        verify(events, never()).saveAndFlush(any());
    }

    @Test
    void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() {
        // Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely
        // concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500.
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
        when(events.findById(id)).thenReturn(Optional.of(existing));
        when(events.saveAndFlush(any()))
                .thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id));
        TimelineEventRequest request = new TimelineEventRequest(
                "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
                null, null, null, 5L, null, null); // in-sync, passes the guard

        assertThatThrownBy(() -> service.update(id, request, secondEditor))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
    }

    // --- delete / getEvent ---

    @Test
    void delete_removes_existing_event() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID());
        when(events.findById(id)).thenReturn(Optional.of(existing));

        service.delete(id);

        verify(events).delete(existing);
    }

    @Test
    void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
        UUID id = UUID.randomUUID();
        when(events.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.delete(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
    }

    @Test
    void getEvent_returns_view_for_existing_event() {
        UUID id = UUID.randomUUID();
        TimelineEvent existing = existingEvent(id, UUID.randomUUID());
        when(events.findById(id)).thenReturn(Optional.of(existing));

        TimelineEventView view = service.getEvent(id);

        assertThat(view.id()).isEqualTo(id);
        assertThat(view.title()).isEqualTo("Original");
    }

    @Test
    void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
        UUID id = UUID.randomUUID();
        when(events.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.getEvent(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
    }
}
