package org.raddatz.familienarchiv.exception;

import java.util.stream.Collectors;

import io.sentry.Sentry;
import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;

import lombok.extern.slf4j.Slf4j;

// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(DomainException.class)
    public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
        var builder = ResponseEntity.status(ex.getStatus());
        if (ex.getRetryAfterSeconds() != null) {
            builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
        }
        return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
        String message = ex.getConstraintViolations().stream()
                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
        return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
        return ResponseEntity.badRequest()
                .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
        return ResponseEntity.status(ex.getStatusCode())
                .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
    }

    /**
     * Backstop for any database integrity violation that slips past the explicit upstream
     * guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into
     * a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream
     * and never reach here; this only catches the unanticipated ones — so it logs the constraint
     * NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response
     * on it (the response stays generic, which is the non-brittle part).
     */
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
        // Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
        // constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
        // offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
        String constraint = constraintNameOf(ex);
        log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
        if ("uq_journey_items_geschichte_position".equals(constraint)) {
            // DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
            return ResponseEntity.status(409)
                    .body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
                            "A position conflict was detected — another request modified this journey simultaneously"));
        }
        return ResponseEntity.badRequest()
                .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
    }

    /**
     * Returns the offending constraint's name from the cause chain, or {@code "unknown"}.
     * Reads only the name (a non-sensitive schema identifier) — never the SQL or the values.
     */
    private static String constraintNameOf(Throwable ex) {
        for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) {
            if (t instanceof org.hibernate.exception.ConstraintViolationException cve
                    && cve.getConstraintName() != null) {
                return cve.getConstraintName();
            }
        }
        return "unknown";
    }

    /**
     * Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
     * conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
     * must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
     * only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
     * version, no persistent-class name.
     *
     * <p>Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
     * back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
     * stable schema constraint NAMES, persistent-class names are not a contract. The precise,
     * code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
     * the net that keeps any current or future write path from regressing to a 500.
     */
    @ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
    public ResponseEntity<ErrorResponse> handleOptimisticLock(
            org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
        // Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
        // ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
        log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
        return ResponseEntity.status(409)
                .body(new ErrorResponse(ErrorCode.CONFLICT,
                        "The resource was modified concurrently. Please reload and try again."));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        Sentry.captureException(ex);
        log.error("Unhandled exception", ex);
        return ResponseEntity.internalServerError()
            .body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
    }

    public record ErrorResponse(ErrorCode code, String message) {}
}