package org.raddatz.familienarchiv.exception;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.sentry.Sentry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;

import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;

@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {

    @InjectMocks
    private GlobalExceptionHandler handler;

    @Test
    void handleGeneric_captures_exception_in_sentry_and_returns_500() {
        RuntimeException ex = new RuntimeException("unexpected failure");

        try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
            ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);

            sentryMock.verify(() -> Sentry.captureException(ex));
            assertThat(response.getStatusCode().value()).isEqualTo(500);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
        }
    }

    @Test
    void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
        // #731: before the fix, a case-colliding alias/name made Hibernate throw
        // NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
        // dedicated handler and falls through to handleGeneric. The fix removes the throw, but
        // this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
        // no "2 results were returned" row count reaching the client (CWE-209).
        IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
                "query did not return a unique result: 2 results were returned", 1, 2);

        try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
            ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);

            assertThat(response.getStatusCode().value()).isEqualTo(500);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
            assertThat(response.getBody().message())
                    .isEqualTo("An unexpected error occurred")
                    .doesNotContain("results were returned")
                    .doesNotContain("NonUnique")
                    .doesNotContain("IncorrectResultSize");
        }
    }

    @Test
    void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
        // A DataIntegrityViolationException carries the constraint name + SQL in its message;
        // the response and logs must never echo it (CWE-209). It must become a clean 400, not a 500.
        DataIntegrityViolationException ex = new DataIntegrityViolationException(
                "could not execute statement; constraint [chk_meta_date_end_after_start]; "
                        + "column meta_date_end of relation documents");

        Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        handlerLogger.addAppender(appender);

        try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
            ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
                    handler.handleDataIntegrityViolation(ex);

            assertThat(response.getStatusCode().value()).isEqualTo(400);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().code()).isEqualTo(ErrorCode.VALIDATION_ERROR);
            assertThat(response.getBody().message())
                    .doesNotContain("chk_")
                    .doesNotContain("meta_date");

            // Defense-in-depth: an unanticipated integrity violation is not a system fault,
            // so it must NOT fabricate a Sentry alert.
            sentryMock.verifyNoInteractions();
        } finally {
            handlerLogger.detachAppender(appender);
        }

        assertThat(appender.list)
                .as("logs a WARN line")
                .anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN));
        assertThat(appender.list)
                .as("never logs the SQL statement / values (would re-leak to Loki)")
                .noneSatisfy(e -> {
                    assertThat(e.getFormattedMessage()).contains("could not execute statement");
                });
    }

    @Test
    void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() {
        // CWE-209 regression: an optimistic-lock failure escaping a service catch must become a
        // generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps
        // it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is
        // mislabeled a timeline one.
        UUID entityId = UUID.randomUUID();
        ObjectOptimisticLockingFailureException ex =
                new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId);

        Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        handlerLogger.addAppender(appender);

        try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
            ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleOptimisticLock(ex);

            assertThat(response.getStatusCode().value()).isEqualTo(409);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT);
            // Body echoes no persistent-class name, no entity id, no version (enumeration aids).
            assertThat(response.getBody().message())
                    .doesNotContain("SomeEntity")
                    .doesNotContain(entityId.toString());

            // A conflict is not a system fault — no fabricated Sentry alert.
            sentryMock.verifyNoInteractions();
        } finally {
            handlerLogger.detachAppender(appender);
        }

        assertThat(appender.list)
                .as("WARN names the persistent class for debuggability")
                .anySatisfy(e -> {
                    assertThat(e.getLevel()).isEqualTo(Level.WARN);
                    assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity");
                });
        assertThat(appender.list)
                .as("but never the entity id (would leak via getMessage())")
                .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString()));
    }

    @Test
    void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
        // Debuggability (DevOps): the WARN must name *which* constraint fired so an
        // unanticipated violation isn't a silent mystery — but it must carry the name only,
        // never the SQL statement or the offending values that the SQLException message holds.
        java.sql.SQLException sql = new java.sql.SQLException(
                "ERROR: violates check constraint; could not execute statement; values (1917-01-10)");
        org.hibernate.exception.ConstraintViolationException cve =
                new org.hibernate.exception.ConstraintViolationException(
                        "constraint violation", sql, "chk_meta_date_end_after_start");
        DataIntegrityViolationException ex = new DataIntegrityViolationException("wrapper", cve);

        Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        handlerLogger.addAppender(appender);

        try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
            ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
                    handler.handleDataIntegrityViolation(ex);

            // Response stays generic and leak-free (CWE-209) regardless of what we log.
            assertThat(response.getStatusCode().value()).isEqualTo(400);
            assertThat(response.getBody().message())
                    .doesNotContain("chk_")
                    .doesNotContain("meta_date");
            sentryMock.verifyNoInteractions();
        } finally {
            handlerLogger.detachAppender(appender);
        }

        assertThat(appender.list)
                .as("WARN names the constraint for debuggability")
                .anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("chk_meta_date_end_after_start"));
        assertThat(appender.list)
                .as("but never the SQL statement or values")
                .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains("could not execute statement"));
    }
}
