package org.raddatz.familienarchiv.geschichte;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

@Service
@RequiredArgsConstructor
@Slf4j
public class GeschichteService {

    private final GeschichteRepository geschichteRepository;
    private final PersonService personService;
    private final DocumentService documentService;
    private final UserService userService;
    private final JourneyItemService journeyItemService;

    /**
     * Allow-list policy for Geschichte body HTML. Tiptap on the writer side
     * already constrains the marks/nodes, but the backend re-sanitises every
     * save so that an attacker calling the API directly cannot inject more.
     */
    private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder()
            .allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li")
            .toFactory();

    private static final int DEFAULT_LIMIT = 50;
    private static final int MAX_LIMIT = 200;

    /** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
    private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");

    // Matches the geschichten.title VARCHAR(255) column (V58) — the service check
    // turns what would be a DB-level 500 into a friendly 400.
    static final int MAX_TITLE_LENGTH = 255;
    // JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
    // same three-layer bound as journey notes: frontend maxlength, this check, and
    // the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
    // unbounded on purpose.
    static final int MAX_INTRO_LENGTH = 4000;

    // ─── Read API ────────────────────────────────────────────────────────────

    public long countPublished() {
        return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
    }

    // readOnly = true: lazy collections resolve within the same tx when called from getView()
    @Transactional(readOnly = true)
    public Geschichte getById(UUID id) {
        Geschichte g = geschichteRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(
                        ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
        if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
            // Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence.
            throw DomainException.notFound(
                    ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
        }
        return g;
    }

    @Transactional(readOnly = true)
    public GeschichteView getView(UUID id) {
        Geschichte g = getById(id);
        List<JourneyItemView> items = journeyItemService.getItems(id);
        return toView(g, items);
    }

    GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
        AppUser author = g.getAuthor();
        GeschichteView.AuthorView authorView = null;
        if (author != null) {
            String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
            if (displayName.isBlank()) displayName = "[Unbekannt]";
            authorView = new GeschichteView.AuthorView(author.getId(), displayName);
        }
        Set<GeschichteView.PersonView> personViews = new HashSet<>();
        for (Person p : g.getPersons()) {
            personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
        }
        return new GeschichteView(
                g.getId(), g.getTitle(), g.getBody(),
                g.getStatus(), g.getType(),
                authorView, personViews,
                items,
                g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
        );
    }

    /**
     * Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
     * must be associated with every person id supplied. An empty or null list applies no
     * person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
     *
     * <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
     * LazyInitializationException on the non-transactional list path.
     *
     * <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
     * Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
     * This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
     */
    public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
        boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
        GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
        int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);

        UUID authorId = isDraftRequest ? currentUser().getId() : null;

        // When personIds is empty, personCount=0 short-circuits the IN() predicate.
        // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
        Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
                ? List.of(NIL_UUID)
                : personIds;
        long personCount = (personIds == null) ? 0 : personIds.size();

        return geschichteRepository
                .findSummaries(effective, authorId, safePersonIds, personCount, documentId)
                .stream()
                .limit(safeLimit)
                .toList();
    }

    // ─── Write API ───────────────────────────────────────────────────────────

    // Write methods return GeschichteView, never the entity: Jackson serializes after
    // the transaction closed, where the lazy items collection is a dead proxy.
    // The view is assembled in-transaction, so no force-init tricks are needed.

    @Transactional
    public GeschichteView create(GeschichteUpdateDTO dto) {
        requireTitle(dto.getTitle());
        GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
        Geschichte g = Geschichte.builder()
                .title(dto.getTitle().trim())
                .body(bodyForType(type, dto.getBody()))
                .status(GeschichteStatus.DRAFT)
                .type(type)
                .author(currentUser())
                .persons(resolvePersons(dto.getPersonIds()))
                .build();
        if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
            g.setStatus(GeschichteStatus.PUBLISHED);
            g.setPublishedAt(LocalDateTime.now());
        }
        Geschichte saved = geschichteRepository.save(g);
        // A freshly created Geschichte has no items by construction — items are only
        // addable via the separate /items endpoints. Revisit if a create DTO ever
        // accepts initial items.
        return toView(saved, List.of());
    }

    @Transactional
    public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
        Geschichte g = geschichteRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(
                        ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
        if (dto.getType() != null && dto.getType() != g.getType()) {
            throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
                    "The type of a Geschichte cannot be changed after creation");
        }
        if (dto.getTitle() != null) {
            requireTitle(dto.getTitle());
            g.setTitle(dto.getTitle().trim());
        }
        if (dto.getBody() != null) {
            g.setBody(bodyForType(g.getType(), dto.getBody()));
        }
        if (dto.getPersonIds() != null) {
            g.setPersons(resolvePersons(dto.getPersonIds()));
        }
        if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
            applyStatusTransition(g, dto.getStatus());
        }
        Geschichte saved = geschichteRepository.save(g);
        return toView(saved, journeyItemService.getItems(id));
    }

    @Transactional
    public void delete(UUID id) {
        if (!geschichteRepository.existsById(id)) {
            throw DomainException.notFound(
                    ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
        }
        geschichteRepository.deleteById(id);
    }

    // ─── private helpers ─────────────────────────────────────────────────────

    private void applyStatusTransition(Geschichte g, GeschichteStatus next) {
        g.setStatus(next);
        if (next == GeschichteStatus.PUBLISHED) {
            g.setPublishedAt(LocalDateTime.now());
        } else {
            g.setPublishedAt(null);
        }
    }

    private void requireTitle(String title) {
        if (title == null || title.trim().isEmpty()) {
            throw DomainException.badRequest(
                    ErrorCode.VALIDATION_ERROR, "Title is required");
        }
        if (title.trim().length() > MAX_TITLE_LENGTH) {
            throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
                    "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
        }
    }

    /**
     * STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
     * JOURNEY intros are plain text: the reader renders them via Svelte text
     * interpolation (never {@code {@html}}), so entity-encoding them here would
     * corrupt content ("&" → "&amp;") and re-encode on every editor round-trip.
     */
    private String bodyForType(GeschichteType type, String body) {
        if (type != GeschichteType.JOURNEY) {
            return sanitize(body);
        }
        if (body != null && body.length() > MAX_INTRO_LENGTH) {
            throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
                    "Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
        }
        return body;
    }

    private String sanitize(String body) {
        if (body == null) return null;
        return BODY_SANITIZER.sanitize(body);
    }

    private Set<Person> resolvePersons(List<UUID> ids) {
        if (ids == null || ids.isEmpty()) return new HashSet<>();
        return new LinkedHashSet<>(personService.getAllById(ids));
    }

    private AppUser currentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            throw DomainException.unauthorized("Authentication required");
        }
        return userService.findByEmail(auth.getName());
    }

    private boolean currentUserHasBlogWrite() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) return false;
        return auth.getAuthorities().stream()
                .anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority()));
    }
}
