package org.raddatz.familienarchiv.importing;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.tag.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import org.raddatz.familienarchiv.tag.TagService;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;

/**
 * Loads {@code canonical-documents.xlsx} into the document domain. Java performs no
 * semantic transformation: the normalizer already resolved people to slugs and dates to
 * ISO values. This loader maps columns by header name, routes each attribution
 * register-first (always retaining the raw cell in {@code sender_text}/{@code receiver_text}),
 * parses clean dates, and keeps the S3/thumbnail plumbing.
 *
 * <p>The import corpus is uniform — every PDF is named {@code <index>.pdf} flat in the import
 * dir — so a document's PDF is resolved <em>directly by its index</em>:
 * {@code importDir.resolve(index + ".pdf")}. The {@code index} is still hostile input
 * regardless of upstream trust (CWE-22 does not care it came from our Python tool): it is
 * validated against a strict catalog pattern with {@link #isValidImportIndex} (no path
 * separators, no {@code .}/{@code ..}, no absolute path, no slash homoglyphs) and the
 * resolved path is asserted to stay inside the import dir in {@link #resolvePdfByIndex} as
 * defense-in-depth. The {@code %PDF} magic-byte check still gates upload.
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class DocumentImporter {

    static final List<String> REQUIRED_HEADERS = List.of(
            "index", "sender_person_id", "sender_name",
            "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision");

    // Catalog index shape: 1–4 letters (ASCII + Latin-1 letters, e.g. the German "ü" in
    // "Mü-0001"), one or more hyphens (the corpus has a few "C--0029" data-entry artefacts),
    // digits, and an optional trailing "x" the normalizer recognises. Anchored, with no
    // separator / dot / slash characters in the class, so "<index>.pdf" can never traverse.
    // NOTE: `\d` here is intentionally ASCII-only ([0-9]). Java's java.util.regex matches `\d`
    // against [0-9] unless Pattern.UNICODE_CHARACTER_CLASS is set — do NOT add that flag, or
    // Arabic-Indic / fullwidth digits would silently widen the accepted set.
    private static final Pattern INDEX_PATTERN =
            Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");

    private final DocumentService documentService;
    private final DocumentTitleFactory documentTitleFactory;
    private final PersonService personService;
    private final TagService tagService;
    private final S3Client s3Client;
    private final ThumbnailAsyncRunner thumbnailAsyncRunner;
    private final FileStreamOpener fileStreamOpener;

    @Value("${app.s3.bucket:familienarchiv}")
    private String bucketName;

    @Value("${app.import.dir:/import}")
    private String importDir;

    /** Outcome of loading the document sheet: processed count + per-file skips. */
    public record LoadResult(int processed, List<ImportStatus.SkippedFile> skippedFiles) {}

    // One transaction for the whole sheet keeps the Hibernate session open so an existing
    // document's lazy receivers collection initialises during an idempotent re-import.
    // Invoked cross-bean from the orchestrator, so the @Transactional proxy applies.
    @Transactional
    public LoadResult load(File artifact) {
        List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
        int processed = 0;
        List<ImportStatus.SkippedFile> skipped = new ArrayList<>();
        // 1-based source row number for ops triage breadcrumbs (the spreadsheet header is row 1,
        // so the first data row is row 2 — matches what an operator sees in the .xlsx).
        int rowNumber = 1;
        for (CanonicalSheetReader.Row row : rows) {
            rowNumber++;
            String index = row.get("index");
            if (index.isBlank()) continue;
            Optional<ImportStatus.SkipReason> skipReason = importRow(row, index, rowNumber);
            if (skipReason.isPresent()) {
                skipped.add(new ImportStatus.SkippedFile(index, skipReason.get()));
            } else {
                processed++;
            }
        }
        log.info("Imported {} documents from {} ({} skipped)", processed, artifact.getName(), skipped.size());
        return new LoadResult(processed, skipped);
    }

    private Optional<ImportStatus.SkipReason> importRow(CanonicalSheetReader.Row row, String index, int rowNumber) {
        if (!isValidImportIndex(index)) {
            // Breadcrumb is the source row number, NOT the raw (possibly-hostile) index — an
            // operator triaging the import can find the offending row in the .xlsx without us
            // echoing attacker-controlled input into the log.
            log.warn("Skipping import row {}: index rejected (fails catalog-shape validation)", rowNumber);
            return Optional.of(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
        }
        Optional<File> resolved = resolvePdfByIndex(index, rowNumber);
        if (resolved.isEmpty()) {
            // Distinct from the "index rejected" skip above: the index is VALID but no
            // <index>.pdf is on disk, so the row becomes a normal PLACEHOLDER (not skipped). The
            // index is a validated catalog id (no hostile content), so it is safe to log here —
            // this surfaces a corpus that drifts from the "<index>.pdf" assumption (e.g. a file
            // that arrived under a different name) rather than dropping it silently.
            log.info("Import row {}: index {} is valid but {}.pdf is absent — creating PLACEHOLDER",
                    rowNumber, index, index);
        } else {
            try {
                if (!isPdfMagicBytes(resolved.get())) {
                    return Optional.of(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
                }
            } catch (IOException e) {
                log.error("Magic-byte check failed for row {}", index, e);
                return Optional.of(ImportStatus.SkipReason.FILE_READ_ERROR);
            }
        }
        return persist(row, index, resolved);
    }

    private Optional<ImportStatus.SkipReason> persist(CanonicalSheetReader.Row row, String index, Optional<File> file) {
        Document existing = documentService.findByOriginalFilename(index).orElse(null);
        if (existing != null && existing.getStatus() != DocumentStatus.PLACEHOLDER) {
            return Optional.of(ImportStatus.SkipReason.ALREADY_EXISTS);
        }

        String s3Key = null;
        String contentType = null;
        DocumentStatus status = DocumentStatus.PLACEHOLDER;
        if (file.isPresent()) {
            contentType = probeContentType(file.get());
            s3Key = "documents/" + UUID.randomUUID() + "_" + file.get().getName();
            try {
                uploadToS3(file.get(), s3Key, contentType);
                status = DocumentStatus.UPLOADED;
            } catch (Exception e) {
                log.error("S3 upload failed for {}", file.get().getName(), e);
                return Optional.of(ImportStatus.SkipReason.S3_UPLOAD_FAILED);
            }
        }

        Document doc = buildDocument(row, index, existing, s3Key, contentType, status);
        Document saved = documentService.save(doc);
        if (file.isPresent()) {
            thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
        }
        return Optional.empty();
    }

    private Document buildDocument(CanonicalSheetReader.Row row, String index, Document existing,
                                   String s3Key, String contentType, DocumentStatus status) {
        Document doc = existing != null ? existing
                : Document.builder().originalFilename(index).build();
        applyAttribution(doc, row);
        applyDates(doc, row);
        applyAuthoritativeAssociations(doc, row);
        applyFileMetadata(doc, s3Key, contentType, status);
        applyComputedFlags(doc);
        return doc;
    }

    // Sender + raw sender/receiver text. The raw cells are always retained verbatim, even
    // when a person is linked — the load-bearing invariant behind the merge story (ADR-025).
    private void applyAttribution(Document doc, CanonicalSheetReader.Row row) {
        String senderName = row.get("sender_name");
        String receiverNames = row.get("receiver_names");
        Person sender = resolveSender(row.get("sender_person_id"), senderName);
        doc.setSender(sender);
        doc.setSenderText(blankToNull(senderName));
        doc.setReceiverText(blankToNull(receiverNames));
    }

    // Date triplet + raw + location. Pure value parsing, no semantic logic.
    private void applyDates(Document doc, CanonicalSheetReader.Row row) {
        doc.setDocumentDate(parseIsoDate(row.get("date_iso")));
        doc.setMetaDatePrecision(parsePrecision(row.get("date_precision")));
        doc.setMetaDateEnd(parseIsoDate(row.get("date_end")));
        doc.setMetaDateRaw(blankToNull(row.get("date_raw")));
        doc.setLocation(blankToNull(row.get("location")));
        doc.setSummary(blankToNull(row.get("summary")));
    }

    // Receivers and tags are owned by the canonical row (ADR-025): clear then re-populate so a
    // shrunk set on re-import prunes stale links rather than accumulating them. The
    // "preserve human edits" rule does NOT extend to these collections.
    private void applyAuthoritativeAssociations(Document doc, CanonicalSheetReader.Row row) {
        Set<Person> receivers = resolveReceivers(row.get("receiver_person_ids"), row.get("receiver_names"));
        doc.getReceivers().clear();
        doc.getReceivers().addAll(receivers);
        attachTag(doc, row.get("tags"));
    }

    // S3 key, content type, status, and the index-derived title. The title formula lives in
    // the document package's DocumentTitleFactory (single source of truth, #726); by this point
    // applyDates has populated the date/location and originalFilename carries the index.
    private void applyFileMetadata(Document doc, String s3Key, String contentType,
                                   DocumentStatus status) {
        doc.setStatus(status);
        doc.setFilePath(s3Key);
        doc.setContentType(contentType);
        doc.setTitle(documentTitleFactory.build(doc));
    }

    // metadataComplete: a document counts as fully described if any of the three "who/when"
    // pieces is filled. Called last so the upstream setters have already populated the doc.
    private void applyComputedFlags(Document doc) {
        doc.setMetadataComplete(doc.getDocumentDate() != null
                || doc.getSender() != null
                || !doc.getReceivers().isEmpty());
    }

    // ─── attribution routing — register-first, always retain raw ─────────────────────

    private Person resolveSender(String slug, String rawName) {
        if (slug.isBlank()) return null;
        return resolvePerson(slug, rawName);
    }

    // Zips the parallel `receiver_person_ids` and `receiver_names` columns by position so an
    // unresolved receiver becomes a provisional Person whose lastName is the human name from
    // `receiver_names`, not the slug. If the names list is shorter than the slugs list (rare —
    // canonical data zips them 1:1), missing entries fall back to slug-as-name.
    private Set<Person> resolveReceivers(String slugs, String names) {
        List<String> slugList = CanonicalSheetReader.splitList(slugs);
        List<String> nameList = CanonicalSheetReader.splitList(names);
        Set<Person> receivers = new LinkedHashSet<>();
        for (int i = 0; i < slugList.size(); i++) {
            String slug = slugList.get(i);
            String name = i < nameList.size() ? nameList.get(i) : slug;
            receivers.add(resolvePerson(slug, name));
        }
        return receivers;
    }

    private Person resolvePerson(String slug, String rawName) {
        return personService.findBySourceRef(slug)
                .orElseGet(() -> personService.upsertBySourceRef(PersonUpsertCommand.builder()
                        .sourceRef(slug)
                        .lastName(blankToNull(rawName) == null ? slug : rawName)
                        .personType(PersonType.PERSON)
                        .provisional(true)
                        .build()));
    }

    // Authoritative: the canonical row defines the document's tags exactly. Clearing first
    // means a tag removed from the row is pruned on re-import (ADR-025).
    private void attachTag(Document doc, String tagPath) {
        doc.getTags().clear();
        if (tagPath.isBlank()) return;
        tagService.findBySourceRef(tagPath).ifPresent(tag -> doc.getTags().add(tag));
    }

    // ─── clean-value parsing (no semantic logic) ─────────────────────────────────────

    private static LocalDate parseIsoDate(String value) {
        if (value == null || value.isBlank()) return null;
        try {
            return LocalDate.parse(value.trim());
        } catch (DateTimeParseException e) {
            return null;
        }
    }

    private static DatePrecision parsePrecision(String value) {
        if (value == null || value.isBlank()) return DatePrecision.UNKNOWN;
        try {
            return DatePrecision.valueOf(value.trim());
        } catch (IllegalArgumentException e) {
            return DatePrecision.UNKNOWN;
        }
    }

    // ─── file handling + S3 (small ≤20-line methods) ─────────────────────────────────

    private String probeContentType(File file) {
        try {
            String probed = Files.probeContentType(file.toPath());
            return probed != null ? probed : "application/octet-stream";
        } catch (IOException e) {
            return "application/octet-stream";
        }
    }

    private void uploadToS3(File file, String s3Key, String contentType) {
        s3Client.putObject(PutObjectRequest.builder()
                        .bucket(bucketName)
                        .key(s3Key)
                        .contentType(contentType)
                        .build(),
                RequestBody.fromFile(file));
    }

    // ─── index validation + containment — defense-in-depth, do not weaken ────────────

    // The index is the only thing that drives the on-disk lookup, so it must never contain a
    // path separator, traversal token, slash homoglyph, null byte, or absolute-path marker —
    // each guard mirrors the filename guards ported from MassImportService — and it must match
    // the strict catalog shape so anything unexpected is skipped loudly rather than read.
    private boolean isValidImportIndex(String index) {
        if (index == null || index.isBlank()) return false;
        if (index.contains("/")) return false;
        if (index.contains("\\")) return false;
        if (index.contains("∕")) return false;  // U+2215 DIVISION SLASH
        if (index.contains("／")) return false;  // U+FF0F FULLWIDTH SOLIDUS
        if (index.contains("⧵")) return false;  // U+29F5 REVERSE SOLIDUS OPERATOR
        if (index.contains(".")) return false;   // no dots — "<index>.pdf" is the only extension
        if (index.contains("\0")) return false;
        if (Paths.get(index).isAbsolute()) return false;
        return INDEX_PATTERN.matcher(index).matches();
    }

    private boolean isPdfMagicBytes(File file) throws IOException {
        // FileStreamOpener is injected so tests can stub a throwing implementation for the
        // IO-error branch without spying on the importer itself.
        try (InputStream is = fileStreamOpener.open(file)) {
            byte[] header = is.readNBytes(4);
            return header.length == 4
                    && header[0] == 0x25  // %
                    && header[1] == 0x50  // P
                    && header[2] == 0x44  // D
                    && header[3] == 0x46; // F
        }
    }

    // O(1) direct lookup: the PDF is exactly importDir/<index>.pdf. The caller has already
    // validated the index shape; the canonical-path containment assertion below is
    // defense-in-depth so even a symlinked <index>.pdf cannot read outside importDir.
    private Optional<File> resolvePdfByIndex(String index, int rowNumber) {
        File baseDir = new File(importDir);
        File candidate = baseDir.toPath().resolve(index + ".pdf").toFile();
        try {
            if (!candidate.isFile()) return Optional.empty();
            String baseDirCanonical = baseDir.getCanonicalPath();
            if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
                throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
            }
            return Optional.of(candidate);
        } catch (IOException e) {
            // Distinct from the deliberate symlink-escape abort above (which throws): canonical
            // resolution itself failed (e.g. the OS rejected the path mid-resolution). We fail
            // safe to a PLACEHOLDER, but never silently — log it so the asymmetry surfaces in ops.
            log.warn("Canonical path resolution failed for import row {}: treating {}.pdf as absent",
                    rowNumber, index, e);
            return Optional.empty();
        }
    }

    private static String blankToNull(String s) {
        return (s == null || s.isBlank()) ? null : s;
    }
}
