package de.gecheckt.pdf.umbenenner.application.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;

/**
 * Unit tests for {@link AiNamingService}.
 * <p>
 * Covers: prompt load failure, AI invocation failure, unparseable response,
 * functional validation failure, and the successful naming proposal path.
 */
@ExtendWith(MockitoExtension.class)
class AiNamingServiceTest {

    private static final String MODEL_NAME = "gpt-4";
    private static final int MAX_CHARS = 1000;
    private static final int TEST_MAX_TITLE_LENGTH = 60;
    private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z");

    @Mock
    private AiInvocationPort aiInvocationPort;

    @Mock
    private PromptPort promptPort;

    private AiResponseValidator validator;
    private AiNamingService service;

    private SourceDocumentCandidate candidate;
    private PreCheckPassed preCheckPassed;

    @BeforeEach
    void setUp() {
        validator = new AiResponseValidator(() -> FIXED_INSTANT, TEST_MAX_TITLE_LENGTH);
        service = new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, MAX_CHARS,
                TEST_MAX_TITLE_LENGTH);

        candidate = new SourceDocumentCandidate(
                "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
        preCheckPassed = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("Document text content", new PdfPageCount(2)));
    }

    // -------------------------------------------------------------------------
    // Helper
    // -------------------------------------------------------------------------

    private static AiRequestRepresentation dummyRequest() {
        return new AiRequestRepresentation(
                new PromptIdentifier("prompt.txt"), "Prompt content", "Document text", 13);
    }

    private static AiInvocationSuccess successWith(String jsonBody) {
        return new AiInvocationSuccess(dummyRequest(), new AiRawResponse(jsonBody),
                de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata.empty());
    }

    private static AiInvocationTechnicalFailure technicalFailure(String reason, String message) {
        return new AiInvocationTechnicalFailure(dummyRequest(), reason, message);
    }

    // -------------------------------------------------------------------------
    // Prompt load failure
    // -------------------------------------------------------------------------

    @Test
    void invoke_promptLoadFailure_returnsAiTechnicalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingFailure("FILE_NOT_FOUND", "Prompt file missing"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiTechnicalFailure.class);
        AiTechnicalFailure failure = (AiTechnicalFailure) result;
        assertThat(failure.errorMessage()).contains("Prompt loading failed");
        assertThat(failure.aiContext().modelName()).isEqualTo(MODEL_NAME);
    }

    // -------------------------------------------------------------------------
    // AI invocation failure
    // -------------------------------------------------------------------------

    @Test
    void invoke_aiInvocationTimeout_returnsAiTechnicalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze this document."));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                technicalFailure("TIMEOUT", "Request timed out after 30s"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiTechnicalFailure.class);
        assertThat(((AiTechnicalFailure) result).errorMessage()).contains("TIMEOUT");
    }

    @Test
    void invoke_aiInvocationConnectionError_returnsAiTechnicalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt content"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                technicalFailure("CONNECTION_ERROR", "Connection refused"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiTechnicalFailure.class);
    }

    // -------------------------------------------------------------------------
    // Response parsing failure (unparseable JSON → technical failure)
    // -------------------------------------------------------------------------

    @Test
    void invoke_unparseableAiResponse_returnsAiTechnicalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("This is not JSON at all"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiTechnicalFailure.class);
        assertThat(((AiTechnicalFailure) result).aiContext().aiRawResponse())
                .isEqualTo("This is not JSON at all");
    }

    @Test
    void invoke_aiResponseMissingTitle_returnsAiTechnicalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"reasoning\":\"No title provided\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiTechnicalFailure.class);
    }

    // -------------------------------------------------------------------------
    // Functional validation failure (parseable but semantically invalid)
    // -------------------------------------------------------------------------

    @Test
    void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        // 61-char title: exceeds the 60-character limit
        String longTitle = "1234567890123456789012345678901234567890123456789012345678901";
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"" + longTitle + "\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiFunctionalFailure.class);
    }

    @Test
    void invoke_aiResponseGenericTitle_returnsAiFunctionalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Dokument\",\"reasoning\":\"Generic\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiFunctionalFailure.class);
    }

    @Test
    void invoke_aiResponseInvalidDateFormat_returnsAiFunctionalFailure() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Rechnung\",\"reasoning\":\"OK\",\"date\":\"15.01.2026\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(AiFunctionalFailure.class);
    }

    // -------------------------------------------------------------------------
    // Successful naming proposal
    // -------------------------------------------------------------------------

    @Test
    void invoke_validAiResponse_returnsNamingProposalReady() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze the document."));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Stromabrechnung\",\"reasoning\":\"Electricity invoice\",\"date\":\"2026-01-15\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(NamingProposalReady.class);
        NamingProposalReady ready = (NamingProposalReady) result;
        assertThat(ready.proposal().validatedTitle()).isEqualTo("Stromabrechnung");
        assertThat(ready.proposal().resolvedDate()).isEqualTo(LocalDate.of(2026, 1, 15));
        assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.AI_PROVIDED);
        assertThat(ready.aiContext().modelName()).isEqualTo(MODEL_NAME);
        assertThat(ready.aiContext().promptIdentifier()).isEqualTo("prompt-v1.txt");
    }

    @Test
    void invoke_validAiResponseWithoutDate_usesFallbackDate() {
        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document\"}"));

        DocumentProcessingOutcome result = service.invoke(preCheckPassed);

        assertThat(result).isInstanceOf(NamingProposalReady.class);
        NamingProposalReady ready = (NamingProposalReady) result;
        assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
        assertThat(ready.proposal().resolvedDate())
                .isEqualTo(FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate());
    }

    @Test
    void invoke_documentTextLongerThanMax_sendsLimitedText() {
        // max chars is 1000, document text is 2000 chars → sent chars should be 1000
        String longText = "X".repeat(2000);
        PreCheckPassed longDoc = new PreCheckPassed(
                candidate, new PdfExtractionSuccess(longText, new PdfPageCount(5)));

        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));

        DocumentProcessingOutcome result = service.invoke(longDoc);

        assertThat(result).isInstanceOf(NamingProposalReady.class);
        NamingProposalReady ready = (NamingProposalReady) result;
        assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(MAX_CHARS);
    }

    @Test
    void invoke_documentTextShorterThanMax_sendsFullText() {
        String shortText = "Short document";
        PreCheckPassed shortDoc = new PreCheckPassed(
                candidate, new PdfExtractionSuccess(shortText, new PdfPageCount(1)));

        when(promptPort.loadPrompt()).thenReturn(
                new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
        when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
                successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));

        DocumentProcessingOutcome result = service.invoke(shortDoc);

        assertThat(result).isInstanceOf(NamingProposalReady.class);
        NamingProposalReady ready = (NamingProposalReady) result;
        assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(shortText.length());
    }

    // -------------------------------------------------------------------------
    // Null handling
    // -------------------------------------------------------------------------

    @Test
    void invoke_nullPreCheckPassed_throwsNullPointerException() {
        assertThatThrownBy(() -> service.invoke(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessage("preCheckPassed must not be null");
    }

    @Test
    void constructor_nullAiPort_throwsNullPointerException() {
        assertThatThrownBy(() -> new AiNamingService(null, promptPort, validator, MODEL_NAME, MAX_CHARS,
                TEST_MAX_TITLE_LENGTH))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    void constructor_nullPromptPort_throwsNullPointerException() {
        assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, null, validator, MODEL_NAME, MAX_CHARS,
                TEST_MAX_TITLE_LENGTH))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    void constructor_nullValidator_throwsNullPointerException() {
        assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, null, MODEL_NAME, MAX_CHARS,
                TEST_MAX_TITLE_LENGTH))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    void constructor_maxTextCharactersZero_throwsIllegalArgumentException() {
        assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 0,
                TEST_MAX_TITLE_LENGTH))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("maxTextCharacters must be >= 1");
    }

    @Test
    void constructor_maxTitleLengthZero_throwsIllegalArgumentException() {
        assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME,
                MAX_CHARS, 0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("maxTitleLength must be >= 1");
    }

    @Test
    void constructor_maxTextCharactersOne_doesNotThrow() {
        // maxTextCharacters=1 is the minimum valid value (boundary test).
        // A changed-conditional-boundary mutation that changes '< 1' to '<= 1' would
        // cause this constructor call to throw — this test detects that mutation.
        new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 1, TEST_MAX_TITLE_LENGTH);
        // No exception expected; reaching this line means the boundary is correct
    }
}
