package org.raddatz.familienarchiv.document;

import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;



import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.validation.annotation.Validated;
import org.raddatz.familienarchiv.document.BatchMetadataRequest;
import org.raddatz.familienarchiv.document.BulkEditError;
import org.raddatz.familienarchiv.document.BulkEditResult;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.document.DocumentVersionSummary;
import org.raddatz.familienarchiv.document.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.raddatz.familienarchiv.document.DocumentVersion;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentVersionService;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequestMapping("/api/documents")
@RequiredArgsConstructor
@Slf4j
@Validated
public class DocumentController {

    private final DocumentService documentService;
    private final DocumentVersionService documentVersionService;
    private final FileService fileService;
    private final UserService userService;

    // --- DOWNLOAD ---
    @GetMapping("/{id}/file")
    public ResponseEntity<InputStreamResource> getDocumentFile(@PathVariable UUID id) {
        Document doc = documentService.getDocumentById(id);

        if (doc.getFilePath() == null) {
            throw DomainException.notFound(ErrorCode.DOCUMENT_NO_FILE, "Document has no file attached: " + id);
        }

        try {
            FileService.S3FileDownload download = fileService.downloadFile(doc.getFilePath());

            String contentType = (doc.getContentType() != null && !doc.getContentType().isBlank())
                    ? doc.getContentType()
                    : download.contentType();

            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType(contentType))
                    .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + doc.getOriginalFilename() + "\"")
                    .body(download.resource());
        } catch (FileService.StorageFileNotFoundException e) {
            throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "File missing in storage: " + doc.getFilePath());
        }
    }

    // --- THUMBNAIL ---
    @GetMapping("/{id}/thumbnail")
    public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
        Document doc = documentService.getDocumentById(id);

        if (doc.getThumbnailKey() == null) {
            throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
        }

        try {
            FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
            return ResponseEntity.ok()
                    .contentType(MediaType.IMAGE_JPEG)
                    // `private` (not `public`) prevents shared caches from serving one user's
                    // thumbnail to another (CWE-525). `immutable` is safe because the URL
                    // carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
                    // the underlying file is replaced.
                    .header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
                    .body(download.resource());
        } catch (FileService.StorageFileNotFoundException e) {
            throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
                    "Thumbnail missing in storage: " + doc.getThumbnailKey());
        }
    }

    // --- METADATA ---
    @GetMapping("/{id}")
    public Document getDocument(@PathVariable UUID id) {
        return documentService.getDocumentDetail(id);
    }

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @RequirePermission(Permission.WRITE_ALL)
    public Document createDocument(
            @ModelAttribute DocumentUpdateDTO dto,
            @RequestPart(value = "file", required = false) MultipartFile file) {
        try {
            return documentService.createDocument(dto, file);
        } catch (IOException e) {
            throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
        }
    }

    @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @RequirePermission(Permission.WRITE_ALL)
    public Document updateDocument(
            @PathVariable UUID id,
            @ModelAttribute DocumentUpdateDTO dto,
            @RequestPart(value = "file", required = false) MultipartFile file,
            Authentication authentication) {
        try {
            return documentService.updateDocument(id, dto, file, requireUserId(authentication));
        } catch (IOException e) {
            throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
        }
    }

    // --- DELETE ---

    @DeleteMapping("/{id}")
    @RequirePermission(Permission.WRITE_ALL)
    public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
        documentService.deleteDocument(id, requireUserId(authentication));
        return ResponseEntity.noContent().build();
    }

    // --- ATTACH FILE ---

    private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
            "application/pdf", "image/jpeg", "image/png", "image/tiff");

    @PostMapping(value = "/{id}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @RequirePermission(Permission.WRITE_ALL)
    public Document attachFile(
            @PathVariable UUID id,
            @RequestPart("file") MultipartFile file,
            Authentication authentication) {
        String contentType = file.getContentType();
        if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: " + contentType);
        }
        return documentService.attachFile(id, file, requireUserId(authentication));
    }

    // --- QUICK UPLOAD ---

    public record UploadError(String filename, String code) {}
    public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}

    @PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @RequirePermission(Permission.WRITE_ALL)
    public QuickUploadResult quickUpload(
            @RequestPart(value = "files", required = false) List<MultipartFile> files,
            @RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
            Authentication authentication) {
        List<Document> created = new ArrayList<>();
        List<Document> updated = new ArrayList<>();
        List<UploadError> errors = new ArrayList<>();

        if (files == null || files.isEmpty()) {
            return new QuickUploadResult(created, updated, errors);
        }

        documentService.validateBatch(files.size(), metadata);

        UUID actorId = requireUserId(authentication);
        long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();

        for (int i = 0; i < files.size(); i++) {
            MultipartFile file = files.get(i);
            if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
                errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
                continue;
            }
            try {
                DocumentService.StoreResult result = metadata != null
                        ? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
                        : documentService.storeDocument(file, actorId);
                if (result.isNew()) {
                    created.add(result.document());
                } else {
                    updated.add(result.document());
                }
            } catch (Exception e) {
                errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
                log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
            }
        }

        log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
                actorId, files.size(), totalBytes, metadata != null,
                created.size(), updated.size(), errors.size());

        return new QuickUploadResult(created, updated, errors);
    }

    // --- BULK EDIT ---

    private static final int BULK_EDIT_MAX_IDS = 500;
    /** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
     *  call from materialising the entire {@code documents} table into JSON.
     *  Generous enough for real-world "Alle X editieren" against the family
     *  archive's bounded scale (~1500 docs today, expected growth to ~5k). */
    private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;

    @PatchMapping("/bulk")
    @RequirePermission(Permission.WRITE_ALL)
    public BulkEditResult patchBulk(
            @RequestBody @Valid DocumentBulkEditDTO dto,
            Authentication authentication) {
        if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
        }
        if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
            throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
                    "Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
        }

        UUID actorId = requireUserId(authentication);
        int updated = 0;
        List<BulkEditError> errors = new ArrayList<>();

        // Dedupe duplicate document IDs while preserving submission order. A
        // double-click on "Alle X editieren" would otherwise hit each document
        // twice and inflate the `updated` count returned to the user.
        LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());

        for (UUID id : uniqueIds) {
            try {
                documentService.applyBulkEditToDocument(id, dto, actorId);
                updated++;
            } catch (DomainException e) {
                errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
            } catch (Exception e) {
                errors.add(new BulkEditError(id, "Internal error"));
                log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
            }
        }

        log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
                actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());

        return new BulkEditResult(updated, errors);
    }

    /** CRLF strip for any log line interpolating a free-form string (e.g.
     *  {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
    private static String sanitizeForLog(String s) {
        return s == null ? null : s.replaceAll("[\\r\\n]", "_");
    }

    @GetMapping("/ids")
    @RequirePermission(Permission.WRITE_ALL)
    public List<UUID> getDocumentIds(
            @RequestParam(required = false) String q,
            @RequestParam(required = false) LocalDate from,
            @RequestParam(required = false) LocalDate to,
            @RequestParam(required = false) UUID senderId,
            @RequestParam(required = false) UUID receiverId,
            @RequestParam(required = false, name = "tag") List<String> tags,
            @RequestParam(required = false) String tagQ,
            @RequestParam(required = false) DocumentStatus status,
            @RequestParam(required = false) String tagOp,
            @RequestParam(required = false) Boolean undated,
            Authentication authentication) {
        TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
        SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
        List<UUID> ids = documentService.findIdsForFilter(filters);
        if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
            throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
                    "Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
        }
        UUID actorId = requireUserId(authentication);
        log.info("documentIds actor={} matched={}", actorId, ids.size());
        return ids;
    }

    @PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
    @RequirePermission(Permission.READ_ALL)
    public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
        if (request == null || request.ids() == null || request.ids().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
        }
        if (request.ids().size() > BULK_EDIT_MAX_IDS) {
            throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
                    "Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
        }
        UUID actorId = requireUserId(authentication);
        log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
        return documentService.batchMetadata(request.ids());
    }

    @GetMapping("/incomplete-count")
    @RequirePermission(Permission.WRITE_ALL)
    public Map<String, Long> getIncompleteCount() {
        return Map.of("count", documentService.getIncompleteCount());
    }

    @GetMapping("/incomplete")
    @RequirePermission(Permission.WRITE_ALL)
    public List<IncompleteDocumentDTO> getIncomplete(
            @Parameter(description = "Maximum number of results (server caps at 200)")
            @RequestParam(defaultValue = "50") int size) {
        return documentService.findIncompleteDocuments(Math.min(size, 200));
    }

    @GetMapping("/incomplete/next")
    @RequirePermission(Permission.WRITE_ALL)
    public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
        return documentService.findNextIncompleteDocument(excludeId)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.noContent().build());
    }

    @GetMapping("/search")
    public ResponseEntity<DocumentSearchResult> search(
            @RequestParam(required = false) String q,
            @RequestParam(required = false) LocalDate from,
            @RequestParam(required = false) LocalDate to,
            @RequestParam(required = false) UUID senderId,
            @RequestParam(required = false) UUID receiverId,
            @RequestParam(required = false, name = "tag") List<String> tags,
            @RequestParam(required = false) String tagQ,
            @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
            @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
            @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
            @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
            @Parameter(description = "Restrict to undated documents (meta_date IS NULL)") @RequestParam(required = false) Boolean undated,
            // @Max on page guards against overflow when pageable.getOffset() is computed
            // as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
            // Hibernate cheerfully turns into an invalid SQL OFFSET.
            @Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
            @Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
        if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
        }
        // tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
        // defaults to AND, which matches the frontend default and keeps old clients working.
        TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
        SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
        Pageable pageable = PageRequest.of(page, size);
        return ResponseEntity.ok(documentService.searchDocuments(filters, sort, dir, pageable));
    }

    @GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<DocumentDensityResult> density(
            @RequestParam(required = false) String q,
            @RequestParam(required = false) UUID senderId,
            @RequestParam(required = false) UUID receiverId,
            @RequestParam(required = false, name = "tag") List<String> tags,
            @RequestParam(required = false) String tagQ,
            @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
            @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
        TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
        DocumentDensityResult result = documentService.getDensity(
                new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
        return ResponseEntity.ok(result);
    }

    // --- TRAINING LABELS ---

    public record TrainingLabelRequest(String label, boolean enrolled) {}

    @PatchMapping("/{id}/training-labels")
    @RequirePermission(Permission.WRITE_ALL)
    @ApiResponse(responseCode = "204")
    public ResponseEntity<Void> patchTrainingLabel(
            @PathVariable UUID id,
            @RequestBody TrainingLabelRequest req) {
        TrainingLabel label;
        try {
            label = TrainingLabel.valueOf(req.label());
        } catch (IllegalArgumentException e) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown training label: " + req.label());
        }
        if (req.enrolled()) {
            documentService.addTrainingLabel(id, label);
        } else {
            documentService.removeTrainingLabel(id, label);
        }
        return ResponseEntity.noContent().build();
    }

    // --- VERSIONS ---

    @GetMapping("/{id}/versions")
    public List<DocumentVersionSummary> getVersions(@PathVariable UUID id) {
        return documentVersionService.getSummaries(id);
    }

    @GetMapping("/{id}/versions/{versionId}")
    public DocumentVersion getVersion(@PathVariable UUID id, @PathVariable UUID versionId) {
        return documentVersionService.getVersion(id, versionId);
    }

    private UUID requireUserId(Authentication authentication) {
        return SecurityUtils.requireUserId(authentication, userService);
    }
}
