DocumentTemplateService.java

package com.taxonomy.templates;

import com.taxonomy.templates.DocumentTemplateGitRepository.PartChange;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateConflictException;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateDescriptor;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateDiff;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateManifest;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateNotFoundException;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateRevision;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateSnapshot;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** Application boundary shared by the web UI and virtual WebDAV projection. */
@Service
public class DocumentTemplateService {

    private static final int DEFAULT_TEXT_PREVIEW_BYTES = 1_048_576;
    static final int COMPARISON_TEXT_PREVIEW_BYTES = 128 * 1024;

    private final DocumentTemplateGitRepository repository;
    private final OoxmlTemplatePackageCodec codec;
    private final OoxmlActiveContentValidator activeContent;
    private final DocumentTemplateMaterializationCache materializations;
    private final Map<String, DocumentTemplateContract> contracts;

    @Autowired
    public DocumentTemplateService(
            DocumentTemplateGitRepository repository,
            OoxmlTemplatePackageCodec codec,
            List<DocumentTemplateContract> contracts,
            OoxmlActiveContentValidator activeContent,
            DocumentTemplateMaterializationCache materializations) {
        this.repository = Objects.requireNonNull(repository, "repository");
        this.codec = Objects.requireNonNull(codec, "codec");
        this.activeContent = Objects.requireNonNull(activeContent, "activeContent");
        this.materializations = Objects.requireNonNull(materializations, "materializations");
        this.contracts = indexContracts(contracts);
    }

    /** Focused-test constructor retaining the existing generic-template contract. */
    public DocumentTemplateService(
            DocumentTemplateGitRepository repository,
            OoxmlTemplatePackageCodec codec,
            List<DocumentTemplateContract> contracts) {
        this(repository, codec, contracts, new OoxmlActiveContentValidator(),
                new DocumentTemplateMaterializationCache());
    }

    public DocumentTemplateService(
            DocumentTemplateGitRepository repository,
            OoxmlTemplatePackageCodec codec) {
        this(repository, codec, List.of());
    }

    public List<TemplateDescriptor> list() throws IOException {
        return repository.list();
    }

    public boolean exists(String templateId) throws IOException {
        DocumentTemplateGitRepository.validateTemplateId(templateId);
        try {
            repository.readCurrent(templateId);
            return true;
        } catch (TemplateNotFoundException exception) {
            return false;
        }
    }

    /** Import one complete DOTX and atomically commit its canonical package tree. */
    public TemplateDescriptor upload(
            String templateId,
            String displayName,
            InputStream dotx,
            String expectedVersion,
            String actor,
            String message) throws IOException {
        DocumentTemplateGitRepository.validateTemplateId(templateId);
        String normalizedDisplayName = normalizeDisplayName(displayName, templateId);
        OoxmlTemplatePackageCodec.PackageData packageData = codec.unpack(dotx);
        validatePackage(templateId, packageData.parts());
        String effectiveExpectedVersion = resolveExpectedVersion(templateId, expectedVersion);
        String user = actor == null || actor.isBlank() ? "taxonomy" : actor;
        TemplateManifest manifest = new TemplateManifest(
                1,
                templateId,
                normalizedDisplayName,
                templateId + ".dotx",
                OoxmlTemplatePackageCodec.DOTX_MEDIA_TYPE,
                Instant.now().toString(),
                user,
                packageData.uncompressedSize(),
                packageData.parts().size(),
                packageData.sha256());
        TemplateSnapshot saved = repository.commit(
                manifest,
                packageData.parts(),
                effectiveExpectedVersion,
                user,
                message);
        return descriptor(saved);
    }

    /** Validated metadata without allocating a downloadable DOTX archive. */
    public TemplateDescriptor describeCurrent(String templateId) throws IOException {
        return describeSnapshot(repository.readCurrent(templateId));
    }

    /** Preserve the selected version; never fall back to the current template. */
    public TemplateDescriptor describe(String templateId, String revision) throws IOException {
        return describeSnapshot(repository.read(templateId, revision));
    }

    private TemplateDescriptor describeSnapshot(TemplateSnapshot snapshot) throws IOException {
        // Retain canonical OOXML and domain/privacy validation without ZIP creation.
        // Download/archive-size validation remains on the unchanged download path.
        codec.validatePackage(snapshot.parts());
        validateSnapshot(snapshot);
        return descriptor(snapshot);
    }

    public TemplateFile downloadCurrent(String templateId) throws IOException {
        return toTemplateFile(repository.readCurrent(templateId));
    }

    public TemplateFile downloadCurrentValidated(String templateId) throws IOException {
        return toTemplateFile(repository.readCurrent(templateId));
    }

    public TemplateFile download(String templateId, String revision) throws IOException {
        return toTemplateFile(repository.read(templateId, revision));
    }

    public List<TemplateRevision> history(String templateId) throws IOException {
        return repository.history(templateId);
    }

    public TemplateDiff diff(String templateId, String fromRevision, String toRevision)
            throws IOException {
        return repository.diff(templateId, fromRevision, toRevision);
    }

    public TemplatePartView readPart(
            String templateId,
            String revision,
            String path) throws IOException {
        OoxmlTemplatePackageCodec.validatePartPath(path);
        TemplateSnapshot snapshot = repository.read(templateId, revision);
        validateSnapshot(snapshot);
        byte[] content = snapshot.parts().get(path);
        if (content == null) {
            throw new TemplateNotFoundException(templateId + "/" + path, revision);
        }
        return partView(path, content, DEFAULT_TEXT_PREVIEW_BYTES);
    }

    /** Read each immutable snapshot once and compare only the requested package part. */
    public TemplatePartComparison comparePart(
            String templateId, String fromRevision, String toRevision, String path) throws IOException {
        OoxmlTemplatePackageCodec.validatePartPath(path);
        fromRevision = canonicalComparisonRevision(fromRevision);
        toRevision = canonicalComparisonRevision(toRevision);
        // Resolve both snapshots before interpreting an absent part as added or deleted.
        TemplateSnapshot before = repository.read(templateId, fromRevision);
        TemplateSnapshot after = fromRevision.equals(toRevision)
                ? before : repository.read(templateId, toRevision);
        validateSnapshot(before);
        if (after != before) validateSnapshot(after);
        byte[] beforeContent = before.parts().get(path);
        byte[] afterContent = after == before ? beforeContent : after.parts().get(path);
        if (beforeContent == null && afterContent == null) {
            throw new TemplateNotFoundException(templateId + "/" + path, fromRevision);
        }
        PartChange change = beforeContent == null ? PartChange.ADDED
                : afterContent == null ? PartChange.DELETED
                : Arrays.equals(beforeContent, afterContent) ? null : PartChange.MODIFIED;
        TemplatePartView beforePart = beforeContent == null ? null
                : partView(path, beforeContent, COMPARISON_TEXT_PREVIEW_BYTES);
        TemplatePartView afterPart = after == before ? beforePart
                : afterContent == null ? null
                        : partView(path, afterContent, COMPARISON_TEXT_PREVIEW_BYTES);
        return new TemplatePartComparison(change, beforePart, afterPart);
    }

    /** Accept full hexadecimal IDs only and preserve one canonical identity for links and reads. */
    static String canonicalComparisonRevision(String revision) {
        if (revision == null || !revision.matches("[0-9a-fA-F]{40}")) {
            throw new IllegalArgumentException("Two immutable template revisions are required");
        }
        return revision.toLowerCase(java.util.Locale.ROOT);
    }

    private static TemplatePartView partView(String path, byte[] content, int maximumTextBytes) {
        String lower = path.toLowerCase(java.util.Locale.ROOT);
        boolean text = lower.endsWith(".xml") || lower.endsWith(".rels")
                || lower.endsWith(".txt") || lower.endsWith(".json");
        String rendered = text && content.length <= maximumTextBytes
                ? decodeExactUtf8(content)
                : null;
        return new TemplatePartView(
                path,
                content.length,
                lower.endsWith(".txt") ? "text/plain" : lower.endsWith(".json") ? "application/json"
                        : text ? "application/xml" : "application/octet-stream",
                rendered);
    }

    private static String decodeExactUtf8(byte[] content) {
        try {
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(content))
                    .toString();
        } catch (CharacterCodingException exception) {
            return null;
        }
    }

    public TemplateDescriptor restore(
            String templateId,
            String revision,
            String expectedVersion,
            String actor) throws IOException {
        TemplateSnapshot historical = repository.read(templateId, revision);
        validateSnapshot(historical);
        OoxmlTemplatePackageCodec.PackageData packageData = codec.unpack(
                new ByteArrayInputStream(codec.pack(historical.parts())));
        validatePackage(templateId, packageData.parts());
        String effectiveExpected = resolveExpectedVersion(templateId, expectedVersion);
        String user = actor == null || actor.isBlank() ? "taxonomy" : actor;
        TemplateManifest restored = new TemplateManifest(
                historical.manifest().schemaVersion(),
                templateId,
                historical.manifest().displayName(),
                historical.manifest().fileName(),
                historical.manifest().mediaType(),
                Instant.now().toString(),
                user,
                packageData.uncompressedSize(),
                packageData.parts().size(),
                packageData.sha256());
        TemplateSnapshot saved = repository.commit(
                restored,
                packageData.parts(),
                effectiveExpected,
                user,
                "Restore document template " + templateId + " from " + revision);
        return descriptor(saved);
    }

    /** Repository-wide head retained only for diagnostics. */
    public String headCommit() throws IOException {
        return repository.headCommit();
    }

    /** Resolve a strong HTTP If-Match list against the current per-template version. */
    String resolveExpectedVersion(String templateId, String value) throws IOException {
        if (value == null || value.isBlank()) {
            return null;
        }
        TemplateSnapshot current;
        try {
            current = repository.readCurrent(templateId);
        } catch (TemplateNotFoundException missing) {
            throw new TemplateConflictException(value.strip(), null);
        }
        String currentCommit = current.commitId();
        for (String candidate : value.split(",")) {
            String tag = candidate.strip();
            if ("*".equals(tag)) {
                return currentCommit;
            }
            if (tag.regionMatches(true, 0, "W/", 0, 2)) {
                continue;
            }
            if (currentCommit.equalsIgnoreCase(stripQuotedEtag(tag))) {
                return currentCommit;
            }
        }
        throw new TemplateConflictException(value.strip(), currentCommit);
    }

    private TemplateFile toTemplateFile(TemplateSnapshot snapshot) throws IOException {
        validateSnapshot(snapshot);
        byte[] dotx = materializations.packed(
                snapshot.manifest().templateId(),
                snapshot.commitId(),
                () -> codec.pack(snapshot.parts()));
        return new TemplateFile(
                snapshot.manifest(),
                snapshot.commitId(),
                dotx,
                parseInstant(snapshot.manifest().updatedAt()));
    }

    private void validateSnapshot(TemplateSnapshot snapshot) throws IOException {
        materializations.validateOnce(
                snapshot.manifest().templateId(),
                snapshot.commitId(),
                () -> validatePackage(snapshot.manifest().templateId(), snapshot.parts()));
    }

    private void validatePackage(String templateId, Map<String, byte[]> packageParts) {
        validateReservedPackagePaths(packageParts);
        activeContent.validate(packageParts);
        DocumentTemplateContract contract = contracts.get(templateId);
        if (contract != null) {
            contract.validate(packageParts);
        }
    }

    private static void validateReservedPackagePaths(Map<String, byte[]> packageParts) {
        for (String path : packageParts.keySet()) {
            String normalized = path.toLowerCase(java.util.Locale.ROOT);
            if (normalized.equals(DocumentTemplateGitRepository.MANIFEST_NAME)
                    || normalized.endsWith("/" + DocumentTemplateGitRepository.MANIFEST_NAME)) {
                throw new IllegalArgumentException(
                        "OOXML package part uses reserved Taxonomy metadata name: " + path);
            }
        }
    }

    private static Map<String, DocumentTemplateContract> indexContracts(
            List<DocumentTemplateContract> contracts) {
        LinkedHashMap<String, DocumentTemplateContract> indexed = new LinkedHashMap<>();
        for (DocumentTemplateContract contract :
                contracts == null ? List.<DocumentTemplateContract>of() : contracts) {
            Objects.requireNonNull(contract, "document template contract");
            DocumentTemplateGitRepository.validateTemplateId(contract.templateId());
            DocumentTemplateContract previous = indexed.putIfAbsent(
                    contract.templateId(), contract);
            if (previous != null) {
                throw new IllegalStateException(
                        "Duplicate document template contract for " + contract.templateId());
            }
        }
        return Map.copyOf(indexed);
    }

    private static TemplateDescriptor descriptor(TemplateSnapshot snapshot) {
        TemplateManifest manifest = snapshot.manifest();
        return new TemplateDescriptor(
                manifest.templateId(), manifest.displayName(), manifest.fileName(),
                snapshot.commitId(), manifest.updatedAt(), manifest.updatedBy(),
                manifest.uncompressedSize(), manifest.partCount(), manifest.packageSha256());
    }

    static String stripEtag(String value) {
        if (value == null) {
            return null;
        }
        String stripped = value.strip();
        if (stripped.startsWith("W/")) {
            stripped = stripped.substring(2).strip();
        }
        return stripQuotedEtag(stripped);
    }

    private static String stripQuotedEtag(String value) {
        String stripped = value == null ? null : value.strip();
        if (stripped != null && stripped.length() >= 2
                && stripped.startsWith("\"") && stripped.endsWith("\"")) {
            return stripped.substring(1, stripped.length() - 1);
        }
        return stripped;
    }

    static String normalizeDisplayName(String displayName, String templateId) {
        String normalized = displayName == null || displayName.isBlank()
                ? templateId : displayName.strip();
        normalized = normalized.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
        for (int offset = 0; offset < normalized.length();) {
            int codePoint = normalized.codePointAt(offset);
            if (!isValidXml10CodePoint(codePoint)) {
                throw new IllegalArgumentException(
                        "Template display name contains a character not permitted in XML 1.0");
            }
            offset += Character.charCount(codePoint);
        }
        if (normalized.length() > 160) {
            throw new IllegalArgumentException(
                    "Template display name must not exceed 160 characters");
        }
        return normalized;
    }

    static boolean isValidXml10CodePoint(int codePoint) {
        return codePoint == 0x9 || codePoint == 0xA || codePoint == 0xD
                || (codePoint >= 0x20 && codePoint <= 0xD7FF)
                || (codePoint >= 0xE000 && codePoint <= 0xFFFD)
                || (codePoint >= 0x10000 && codePoint <= 0x10FFFF);
    }

    private static Instant parseInstant(String value) {
        try {
            return Instant.parse(value);
        } catch (RuntimeException exception) {
            return Instant.EPOCH;
        }
    }

    public record TemplateFile(
            TemplateManifest manifest,
            String commitId,
            byte[] content,
            Instant lastModified) {
        public TemplateFile {
            Objects.requireNonNull(manifest, "manifest");
            Objects.requireNonNull(commitId, "commitId");
            content = content.clone();
        }
        @Override public byte[] content() { return content.clone(); }
        public String etag() { return "\"" + commitId + "\""; }
    }

    /** A null change denotes unchanged bytes, as in TemplateDiff's absent change entry. */
    public record TemplatePartComparison(
            PartChange change, TemplatePartView before, TemplatePartView after) { }

    public record TemplatePartView(
            String path,
            long size,
            String mediaType,
            String textContent) {
    }
}