package at.procon.dip.ingestion.service;

import at.procon.dip.classification.service.DocumentClassificationService;
import at.procon.dip.classification.spi.DetectionResult;
import at.procon.dip.domain.access.DocumentAccessContext;
import at.procon.dip.domain.access.DocumentVisibility;
import at.procon.dip.domain.document.CanonicalDocumentMetadata;
import at.procon.dip.domain.document.ContentRole;
import at.procon.dip.domain.document.DocumentStatus;
import at.procon.dip.domain.document.StorageType;
import at.procon.dip.domain.document.entity.Document;
import at.procon.dip.domain.document.entity.DocumentSource;
import at.procon.dip.domain.document.entity.DocumentContent;
import at.procon.dip.domain.document.repository.DocumentRepository;
import at.procon.dip.domain.document.repository.DocumentSourceRepository;
import at.procon.dip.domain.document.service.DocumentContentService;
import at.procon.dip.domain.document.service.DocumentRepresentationService;
import at.procon.dip.domain.document.service.DocumentService;
import at.procon.dip.domain.document.service.DocumentSourceService;
import at.procon.dip.domain.document.service.command.AddDocumentContentCommand;
import at.procon.dip.domain.document.service.command.AddDocumentSourceCommand;
import at.procon.dip.domain.document.service.command.AddDocumentTextRepresentationCommand;
import at.procon.dip.domain.document.service.command.CreateDocumentCommand;
import at.procon.dip.embedding.config.EmbeddingProperties;
import at.procon.dip.embedding.policy.EmbeddingPolicy;
import at.procon.dip.embedding.policy.EmbeddingProfile;
import at.procon.dip.embedding.registry.EmbeddingModelRegistry;
import at.procon.dip.embedding.service.EmbeddingModelCatalogService;
import at.procon.dip.embedding.service.EmbeddingPolicyResolver;
import at.procon.dip.embedding.service.EmbeddingProfileResolver;
import at.procon.dip.embedding.service.RepresentationEmbeddingOrchestrator;
import at.procon.dip.extraction.service.DocumentExtractionService;
import at.procon.dip.extraction.spi.ExtractionRequest;
import at.procon.dip.extraction.spi.ExtractionResult;
import at.procon.dip.ingestion.config.DipIngestionProperties;
import at.procon.dip.ingestion.dto.ImportedDocumentResult;
import at.procon.dip.ingestion.spi.OriginalContentStoragePolicy;
import at.procon.dip.ingestion.spi.SourceDescriptor;
import at.procon.dip.ingestion.service.IngestionInternalAttributes;
import at.procon.dip.ingestion.util.DocumentImportSupport;
import at.procon.dip.normalization.service.TextRepresentationBuildService;
import at.procon.dip.normalization.spi.RepresentationBuildRequest;
import at.procon.dip.normalization.spi.TextRepresentationDraft;
import at.procon.dip.processing.service.StructuredDocumentProcessingService;
import at.procon.dip.processing.spi.DocumentProcessingPolicy;
import at.procon.dip.processing.spi.StructuredProcessingRequest;
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
import at.procon.dip.runtime.config.RuntimeMode;
import at.procon.ted.util.HashUtils;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

@Service
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
@RequiredArgsConstructor
@Slf4j
public class GenericDocumentImportService {

    private final DipIngestionProperties properties;
    private final DocumentRepository documentRepository;
    private final DocumentSourceRepository documentSourceRepository;
    private final DocumentService documentService;
    private final DocumentSourceService documentSourceService;
    private final DocumentContentService documentContentService;
    private final DocumentRepresentationService documentRepresentationService;
    private final DocumentClassificationService classificationService;
    private final DocumentExtractionService extractionService;
    private final TextRepresentationBuildService representationBuildService;
    private final StructuredDocumentProcessingService structuredProcessingService;
    private final EmbeddingProperties embeddingProperties;
    private final EmbeddingModelRegistry embeddingModelRegistry;
    private final EmbeddingModelCatalogService embeddingModelCatalogService;
    private final RepresentationEmbeddingOrchestrator representationEmbeddingOrchestrator;
    private final EmbeddingPolicyResolver embeddingPolicyResolver;
    private final EmbeddingProfileResolver embeddingProfileResolver;

    @Transactional
    public ImportedDocumentResult importDocument(SourceDescriptor sourceDescriptor) {
        ResolvedPayload payload = resolvePayload(sourceDescriptor);
        DetectionResult detection = classificationService.detect(withResolvedMediaType(sourceDescriptor, payload));
        String dedupHash = HashUtils.computeSha256(payload.binaryContent());

        DocumentAccessContext accessContext = sourceDescriptor.accessContext() == null
                ? defaultAccessContext()
                : sourceDescriptor.accessContext();

        Optional<Document> existingBySource = resolveExistingBySourceIdentifier(sourceDescriptor);
        if (existingBySource.isPresent()) {
            Document document = existingBySource.get();
            List<String> warnings = List.of("Source identifier already imported; returning existing document");
            return new ImportedDocumentResult(document, detection, warnings, true);
        }

        if (properties.isDeduplicateByContentHash()) {
            Optional<Document> existing = resolveDeduplicatedDocument(dedupHash, accessContext);
            if (existing.isPresent()) {
                Document document = existing.get();
                ensureSource(document, sourceDescriptor);
                List<String> warnings = List.of("Document content hash already imported within the same access scope; linked new source to existing document");
                return new ImportedDocumentResult(document, detection, warnings, true);
            }
        }

        Document document = documentService.create(new CreateDocumentCommand(
                accessContext.ownerTenant() == null ? null : accessContext.ownerTenant().tenantKey(),
                accessContext.visibility(),
                detection.documentType(),
                detection.documentFamily(),
                DocumentStatus.RECEIVED,
                determineInitialTitle(sourceDescriptor, detection, payload),
                null,
                detection.languageCode(),
                detection.mimeType(),
                buildBusinessKey(sourceDescriptor),
                dedupHash
        ));

        ensureSource(document, sourceDescriptor);
        documentService.updateStatus(document.getId(), DocumentStatus.CLASSIFIED);

        boolean persistOriginalContent = shouldPersistOriginalContent(sourceDescriptor);
        DocumentContent originalContent = persistOriginalContent
                ? persistOriginalContent(document, sourceDescriptor, detection, payload, dedupHash)
                : null;

        List<String> warnings = new ArrayList<>();
        ExtractionResult extractionResult = emptyExtractionResult();
        Map<ContentRole, DocumentContent> persistedDerivedContent = java.util.Collections.emptyMap();

        if (persistOriginalContent) {
            DocumentProcessingPolicy processingPolicy = structuredProcessingService.resolvePolicy(sourceDescriptor, detection);

            if (processingPolicy.runGenericExtraction()) {
                extractionResult = extractionService.extract(new ExtractionRequest(
                        sourceDescriptor,
                        detection,
                        payload.textContent(),
                        payload.binaryContent()
                ));
                warnings.addAll(extractionResult.warnings());
            }

            if (processingPolicy.invokeStructuredProcessor()) {
                var structuredResult = structuredProcessingService.process(new StructuredProcessingRequest(
                        document,
                        originalContent,
                        sourceDescriptor,
                        detection,
                        payload.binaryContent(),
                        payload.textContent(),
                        dedupHash
                ));
                if (structuredResult.isPresent()) {
                    extractionResult = mergeExtractionResults(extractionResult, structuredResult.get());
                    warnings.addAll(structuredResult.get().warnings());
                }
            }

            if (processingPolicy.persistExtractedContent()) {
                persistedDerivedContent = persistDerivedContent(document, detection, extractionResult, dedupHash);
                documentService.updateStatus(document.getId(), DocumentStatus.EXTRACTED);
            }

            if (processingPolicy.runRepresentationBuilders()) {
                var drafts = representationBuildService.build(new RepresentationBuildRequest(sourceDescriptor, detection, extractionResult));
                persistRepresentationsAndEmbeddings(document, originalContent, persistedDerivedContent, drafts, sourceDescriptor);
            }

            if (processingPolicy.applyStructuredTitleIfMissing() && !extractionResult.structuredPayloads().isEmpty()) {
                applyStructuredTitleIfMissing(documentService.getRequired(document.getId()), extractionResult);
            }
        } else {
            warnings.add("Original content storage disabled for this document; skipped extraction and text-representation processing");
        }

        Document reloaded = documentService.getRequired(document.getId());
        if (reloaded.getStatus() == DocumentStatus.EXTRACTED) {
            documentService.updateStatus(reloaded.getId(), DocumentStatus.REPRESENTED);
            reloaded = documentService.getRequired(reloaded.getId());
        }

        return new ImportedDocumentResult(reloaded, detection, warnings, false);
    }

    private SourceDescriptor withResolvedMediaType(SourceDescriptor sourceDescriptor, ResolvedPayload payload) {
        return new SourceDescriptor(
                sourceDescriptor.accessContext(),
                sourceDescriptor.sourceType(),
                sourceDescriptor.sourceIdentifier(),
                sourceDescriptor.sourceUri(),
                sourceDescriptor.fileName(),
                payload.mediaType(),
                sourceDescriptor.binaryContent(),
                sourceDescriptor.textContent(),
                sourceDescriptor.receivedAt(),
                sourceDescriptor.originalContentStoragePolicy(),
                sourceDescriptor.attributes()
        );
    }

    private ResolvedPayload resolvePayload(SourceDescriptor sourceDescriptor) {
        byte[] binary = sourceDescriptor.binaryContent();
        String text = sourceDescriptor.textContent();

        if ((binary == null || binary.length == 0) && StringUtils.hasText(text)) {
            binary = text.getBytes(StandardCharsets.UTF_8);
        }
        if ((binary == null || binary.length == 0) && StringUtils.hasText(sourceDescriptor.sourceUri())) {
            try {
                java.nio.file.Path path = java.nio.file.Path.of(sourceDescriptor.sourceUri());
                if (java.nio.file.Files.exists(path)) {
                    binary = java.nio.file.Files.readAllBytes(path);
                }
            } catch (Exception e) {
                throw new IllegalArgumentException("Failed to read source payload from " + sourceDescriptor.sourceUri(), e);
            }
        }
        if (binary == null || binary.length == 0) {
            throw new IllegalArgumentException("No payload content available for source " + sourceDescriptor.sourceIdentifier());
        }
        if (!StringUtils.hasText(text) && DocumentImportSupport.isLikelyTextMime(sourceDescriptor.mediaType())) {
            text = new String(binary, StandardCharsets.UTF_8);
        }

        return new ResolvedPayload(binary, text, inferMediaType(sourceDescriptor));
    }

    private String inferMediaType(SourceDescriptor sourceDescriptor) {
        return DocumentImportSupport.inferKnownMimeType(sourceDescriptor.fileName(), sourceDescriptor.mediaType());
    }

    private DocumentAccessContext defaultAccessContext() {
        String tenantKey = properties.getDefaultOwnerTenantKey();
        DocumentVisibility visibility = properties.getDefaultVisibility();
        if (!StringUtils.hasText(tenantKey)) {
            return new DocumentAccessContext(null, visibility);
        }
        return new DocumentAccessContext(new at.procon.dip.domain.tenant.TenantRef(null, tenantKey, tenantKey), visibility);
    }

    private String determineInitialTitle(SourceDescriptor sourceDescriptor, DetectionResult detection, ResolvedPayload payload) {
        if (sourceDescriptor.attributes() != null && StringUtils.hasText(sourceDescriptor.attributes().get("title"))) {
            return sourceDescriptor.attributes().get("title");
        }
        if (StringUtils.hasText(sourceDescriptor.fileName())) {
            return sourceDescriptor.fileName();
        }
        if (StringUtils.hasText(payload.textContent())) {
            for (String line : payload.textContent().split("\n")) {
                if (StringUtils.hasText(line)) {
                    return DocumentImportSupport.ellipsize(line.trim(), 240);
                }
            }
        }
        return detection.documentType().name();
    }

    private String buildBusinessKey(SourceDescriptor sourceDescriptor) {
        if (StringUtils.hasText(sourceDescriptor.sourceIdentifier())) {
            return sourceDescriptor.sourceType() + ":" + sourceDescriptor.sourceIdentifier();
        }
        if (StringUtils.hasText(sourceDescriptor.sourceUri())) {
            return sourceDescriptor.sourceType() + ":" + sourceDescriptor.sourceUri();
        }
        return sourceDescriptor.sourceType() + ":" + java.util.UUID.randomUUID();
    }

    private void ensureSource(Document document, SourceDescriptor sourceDescriptor) {
        boolean alreadyLinked = documentSourceRepository.findByDocument_Id(document.getId()).stream().anyMatch(existing ->
                equalsNullable(existing.getExternalSourceId(), sourceDescriptor.sourceIdentifier())
                        && equalsNullable(existing.getSourceUri(), sourceDescriptor.sourceUri())
                        && equalsNullable(existing.getSourceFilename(), sourceDescriptor.fileName()));
        if (alreadyLinked) {
            return;
        }

        String importBatchId = sourceDescriptor.attributes() != null && StringUtils.hasText(sourceDescriptor.attributes().get("importBatchId"))
                ? sourceDescriptor.attributes().get("importBatchId")
                : properties.getImportBatchId();

        documentSourceService.addSource(new AddDocumentSourceCommand(
                document.getId(),
                sourceDescriptor.sourceType(),
                sourceDescriptor.sourceIdentifier(),
                sourceDescriptor.sourceUri(),
                sourceDescriptor.fileName(),
                null,
                importBatchId,
                sourceDescriptor.receivedAt() == null ? OffsetDateTime.now() : sourceDescriptor.receivedAt()
        ));
    }

    private boolean shouldPersistOriginalContent(SourceDescriptor sourceDescriptor) {
        if (sourceDescriptor.originalContentStoragePolicy() == OriginalContentStoragePolicy.STORE) {
            return true;
        }
        if (sourceDescriptor.originalContentStoragePolicy() == OriginalContentStoragePolicy.SKIP) {
            return false;
        }
        if (properties.isStoreOriginalContentForWrapperDocuments()) {
            return true;
        }
        return !isWrapperDocument(sourceDescriptor);
    }

    private boolean isWrapperDocument(SourceDescriptor sourceDescriptor) {
        if (sourceDescriptor.attributes() == null || sourceDescriptor.attributes().isEmpty()) {
            return false;
        }
        String wrapperFlag = sourceDescriptor.attributes().get("wrapperDocument");
        if (wrapperFlag == null) {
            wrapperFlag = sourceDescriptor.attributes().get("containerDocument");
        }
        return Boolean.parseBoolean(wrapperFlag);
    }

    private DocumentContent persistOriginalContent(Document document,
                                                   SourceDescriptor sourceDescriptor,
                                                   DetectionResult detection,
                                                   ResolvedPayload payload,
                                                   String dedupHash) {
        boolean storeBinaryInDb = shouldStoreBinaryInDb(payload.binaryContent());
        boolean textPreferred = DocumentImportSupport.isLikelyTextMime(detection.mimeType()) || sourceDescriptor.hasInlineTextContent();

        return documentContentService.addContent(new AddDocumentContentCommand(
                document.getId(),
                ContentRole.ORIGINAL,
                textPreferred ? StorageType.DB_TEXT : (storeBinaryInDb ? StorageType.DB_BINARY : StorageType.EXTERNAL_REFERENCE),
                detection.mimeType(),
                textPreferred ? StandardCharsets.UTF_8.name() : null,
                textPreferred ? payload.textContent() : null,
                storeBinaryInDb && !textPreferred ? payload.binaryContent() : null,
                !storeBinaryInDb && !textPreferred ? sourceDescriptor.sourceUri() : null,
                dedupHash,
                (long) payload.binaryContent().length
        ));
    }

    private boolean shouldStoreBinaryInDb(byte[] binaryContent) {
        return properties.isStoreOriginalBinaryInDb()
                && binaryContent != null
                && binaryContent.length <= properties.getMaxBinaryBytesInDb();
    }

    private Map<ContentRole, DocumentContent> persistDerivedContent(Document document,
                                                                    DetectionResult detection,
                                                                    ExtractionResult extractionResult,
                                                                    String baseHash) {
        Map<ContentRole, DocumentContent> result = new LinkedHashMap<>();
        extractionResult.derivedTextByRole().forEach((role, text) -> {
            if (!StringUtils.hasText(text)) {
                return;
            }
            String contentHash = HashUtils.computeSha256(baseHash + ":" + role.name() + ":" + text);
            DocumentContent content = documentContentService.addContent(new AddDocumentContentCommand(
                    document.getId(),
                    role,
                    StorageType.DB_TEXT,
                    detection.mimeType(),
                    StandardCharsets.UTF_8.name(),
                    text,
                    null,
                    null,
                    contentHash,
                    (long) text.length()
            ));
            result.put(role, content);
        });
        return result;
    }

    private void persistRepresentationsAndEmbeddings(Document document,
                                                     DocumentContent originalContent,
                                                     Map<ContentRole, DocumentContent> derivedContent,
                                                     List<TextRepresentationDraft> drafts,
                                                     SourceDescriptor sourceDescriptor) {
        if (drafts == null || drafts.isEmpty()) {
            return;
        }

        EmbeddingPolicy embeddingPolicy = null;
        EmbeddingProfile embeddingProfile = null;
        if (embeddingProperties.isEnabled()) {
            embeddingPolicy = embeddingPolicyResolver.resolve(document, sourceDescriptor);
            if (embeddingPolicy != null && embeddingPolicy.enabled()) {
                embeddingModelRegistry.getRequired(embeddingPolicy.modelKey());
                embeddingModelCatalogService.ensureRegistered(embeddingPolicy.modelKey());
                embeddingProfile = embeddingProfileResolver.resolve(embeddingPolicy.profileKey());
                log.debug("Resolved embedding policy {} for document {} -> model={}, profile={}",
                        embeddingPolicy.policyKey(), document.getId(), embeddingPolicy.modelKey(), embeddingPolicy.profileKey());
            } else if (embeddingPolicy != null) {
                log.debug("Resolved disabled embedding policy {} for document {}", embeddingPolicy.policyKey(), document.getId());
            }
        }

        boolean deferLexicalIndex = shouldDeferLexicalIndex(sourceDescriptor);

        for (TextRepresentationDraft draft : drafts) {
            if (!StringUtils.hasText(draft.textBody())) {
                continue;
            }

            DocumentContent linkedContent = resolveLinkedContent(draft, originalContent, derivedContent);

            var representation = documentRepresentationService.addRepresentation(new AddDocumentTextRepresentationCommand(
                    document.getId(),
                    linkedContent == null ? null : linkedContent.getId(),
                    draft.representationType(),
                    StringUtils.hasText(draft.builderKey()) ? draft.builderKey() : "phase4-generic-builder",
                    draft.languageCode(),
                    null,
                    draft.chunkIndex(),
                    draft.chunkStartOffset(),
                    draft.chunkEndOffset(),
                    draft.primary(),
                    draft.textBody(),
                    deferLexicalIndex
            ));

            if (shouldQueueEmbedding(draft, embeddingPolicy, embeddingProfile)) {
                representationEmbeddingOrchestrator.enqueueRepresentation(
                        document.getId(),
                        representation.getId(),
                        embeddingPolicy.modelKey()
                );
            }
        }
        documentService.updateStatus(document.getId(), DocumentStatus.REPRESENTED);
    }

    private boolean shouldDeferLexicalIndex(SourceDescriptor sourceDescriptor) {
        return IngestionInternalAttributes.isTruthy(sourceDescriptor.attributes(), IngestionInternalAttributes.DEFER_LEXICAL_INDEX);
    }

    private DocumentContent resolveLinkedContent(TextRepresentationDraft draft,
                                                 DocumentContent originalContent,
                                                 Map<ContentRole, DocumentContent> derivedContent) {
        if (draft.sourceContentRole() != null && derivedContent.containsKey(draft.sourceContentRole())) {
            return derivedContent.get(draft.sourceContentRole());
        }
        return derivedContent.getOrDefault(ContentRole.NORMALIZED_TEXT, originalContent);
    }

    private boolean shouldQueueEmbedding(TextRepresentationDraft draft,
                                         EmbeddingPolicy embeddingPolicy,
                                         EmbeddingProfile embeddingProfile) {
        if (embeddingPolicy == null || !embeddingPolicy.enabled() || embeddingProfile == null) {
            return false;
        }
        if (!embeddingProfile.includes(draft.representationType())) {
            return false;
        }
        if (draft.queueForEmbedding() != null) {
            return draft.queueForEmbedding();
        }
        return properties.isVectorizePrimaryRepresentationOnly() ? draft.primary() : true;
    }

    private ExtractionResult mergeExtractionResults(ExtractionResult base, ExtractionResult override) {
        Map<ContentRole, String> mergedText = new LinkedHashMap<>();
        if (base != null && base.derivedTextByRole() != null) {
            mergedText.putAll(base.derivedTextByRole());
        }
        if (override != null && override.derivedTextByRole() != null) {
            mergedText.putAll(override.derivedTextByRole());
        }

        List<at.procon.dip.extraction.spi.ExtractedStructuredPayload> mergedPayloads = new ArrayList<>();
        if (base != null && base.structuredPayloads() != null) {
            mergedPayloads.addAll(base.structuredPayloads());
        }
        if (override != null && override.structuredPayloads() != null) {
            mergedPayloads.addAll(override.structuredPayloads());
        }

        List<String> mergedWarnings = new ArrayList<>();
        if (base != null && base.warnings() != null) {
            mergedWarnings.addAll(base.warnings());
        }
        if (override != null && override.warnings() != null) {
            mergedWarnings.addAll(override.warnings());
        }

        return new ExtractionResult(mergedText, mergedPayloads, mergedWarnings);
    }

    private void applyStructuredTitleIfMissing(Document document, ExtractionResult extractionResult) {
        boolean missingTitle = !StringUtils.hasText(document.getTitle()) || document.getTitle().equals(document.getDocumentType().name());
        if (!missingTitle) {
            return;
        }
        for (var payload : extractionResult.structuredPayloads()) {
            if (payload.attributes() == null) {
                continue;
            }
            Object title = payload.attributes().get("title");
            if (title instanceof String titleValue && StringUtils.hasText(titleValue)) {
                document.setTitle(titleValue);
                documentService.save(document);
                return;
            }
        }
    }

    private boolean equalsNullable(String left, String right) {
        return java.util.Objects.equals(left, right);
    }

    private Optional<Document> resolveDeduplicatedDocument(String dedupHash, DocumentAccessContext accessContext) {
        return documentRepository.findByDedupHash(dedupHash).stream()
                .filter(document -> matchesAccessContext(document, accessContext))
                .findFirst();
    }

    private Optional<Document> resolveExistingBySourceIdentifier(SourceDescriptor sourceDescriptor) {
        if (!StringUtils.hasText(sourceDescriptor.sourceIdentifier())) {
            return Optional.empty();
        }
        return documentSourceRepository.findBySourceTypeAndExternalSourceId(
                        sourceDescriptor.sourceType(),
                        sourceDescriptor.sourceIdentifier())
                .map(DocumentSource::getDocument);
    }

    private boolean matchesAccessContext(Document document, DocumentAccessContext accessContext) {
        String expectedTenantKey = accessContext.ownerTenant() == null ? null : accessContext.ownerTenant().tenantKey();
        if (!equalsNullable(document.getOwnerTenant() != null ? document.getOwnerTenant().getTenantKey() : null, expectedTenantKey)) {
            return false;
        }
        return document.getVisibility() == accessContext.visibility();
    }

    private ExtractionResult emptyExtractionResult() {
        return new ExtractionResult(Map.of(), List.of(), List.of());
    }

    private CanonicalDocumentMetadata buildCanonicalMetadata( Document document,
                                                             DetectionResult detection,
                                                             SourceDescriptor sourceDescriptor,
                                                             ExtractionResult extractionResult) {
        return document.toCanonicalMetadata();
    }

    private record ResolvedPayload(byte[] binaryContent, String textContent, String mediaType) {
    }
}
