TextApiV2Resource.java

package info.textgrid.services.textapi;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;

import info.textgrid.namespaces.metadata.agent._2010.AgentType;
import info.textgrid.namespaces.metadata.core._2010.EditionType;
import info.textgrid.namespaces.metadata.core._2010.ObjectType;
import info.textgrid.namespaces.middleware.tgsearch.ResultType;
import info.textgrid.services.textapi.apiv2.Agent;
import info.textgrid.services.textapi.apiv2.Collection;
import info.textgrid.services.textapi.apiv2.CollectionReference;
import info.textgrid.services.textapi.apiv2.Item;
import info.textgrid.services.textapi.FieldOverrideService;
import info.textgrid.services.textapi.apiv2.LicenseMapper;
import info.textgrid.services.textapi.apiv2.Manifest;
import info.textgrid.services.textapi.apiv2.SimpleReference;
import info.textgrid.services.textapi.services.SearchClientService;
import io.quarkus.runtime.LaunchMode;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HEAD;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import javax.xml.datatype.XMLGregorianCalendar;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

@Path("/textapi/v2")
public class TextApiV2Resource {

    /**
     * Extract the project ID from the metadata.
     * The project ID is typically in the generated/Project/Id field.
     */
    @SuppressWarnings("unchecked")
    private String extractProjectId(Object object) {
        try {
            var getGeneric = object.getClass().getMethod("getGeneric");
            var generic = getGeneric.invoke(object);
            var getGenerated = generic.getClass().getMethod("getGenerated");
            var generated = getGenerated.invoke(generic);
            var getProject = generated.getClass().getMethod("getProject");
            var project = getProject.invoke(generated);
            if (project != null) {
                var getId = project.getClass().getMethod("getId");
                return (String) getId.invoke(project);
            }
        } catch (Exception e) {
            // Fall back to null
        }
        return null;
    }

    /**
     * Apply field overrides to a manifest based on the project configuration.
     */
    private void applyFieldOverrides(String projectId, Manifest manifest, String contextSlug) {
        var config = fieldOverrideService.loadConfig(projectId);
        if (config != null) {
            fieldOverrideService.applyManifestOverrides(config, manifest, contextSlug);
        }
    }

    /**
     * Check if the given format is a valid aggregation format.
     * Accepts: text/tg.aggregation+xml, text/tg.collection+tg.aggregation+xml, text/tg.edition+tg.aggregation+xml
     */
    private boolean isAggregationFormat(String format) {
        return format != null && (
            format.equals("text/tg.aggregation+xml") ||
            format.equals("text/tg.collection+tg.aggregation+xml") ||
            format.equals("text/tg.edition+tg.aggregation+xml")
        );
    }

    /**
     * Take the "created" timestamp from the tgsearch response.
     * It is read from the "created" child node of the "generated" element of
     * the object's metadata. Falls back to the fallback value (current time)
     * if the timestamp is not present.
     */
    private String getCreatedTimestamp(ObjectType object, String fallback) {
        if (object == null || object.getGeneric() == null
                || object.getGeneric().getGenerated() == null
                || object.getGeneric().getGenerated().getCreated() == null) {
            return fallback;
        }
        return object.getGeneric().getGenerated().getCreated().toString();
    }

    /**
     * Take the "modified" timestamp from the tgsearch response.
     * It is read from the "lastModified" child node of the "generated" element
     * of the object's metadata. Falls back to the fallback value (current time)
     * if the timestamp is not present.
     */
    private String getModifiedTimestamp(ObjectType object, String fallback) {
        if (object == null || object.getGeneric() == null
                || object.getGeneric().getGenerated() == null
                || object.getGeneric().getGenerated().getLastModified() == null) {
            return fallback;
        }
        return object.getGeneric().getGenerated().getLastModified().toString();
    }

    /**
     * Take the project name from the tgsearch response.
     * It is read from the text node of the "project" child node of the
     * "generated" element of the object's metadata. Returns null if the
     * project name is not present.
     */
    private String getProjectName(ObjectType object) {
        if (object == null || object.getGeneric() == null
                || object.getGeneric().getGenerated() == null
                || object.getGeneric().getGenerated().getProject() == null
                || object.getGeneric().getGenerated().getProject().getValue() == null
                || object.getGeneric().getGenerated().getProject().getValue().isBlank()) {
            return null;
        }
        return object.getGeneric().getGenerated().getProject().getValue();
    }

    /**
     * Resolve the short title for a referenced object from its TextGrid metadata.
     * Prefers the title already contained in the search result and falls back to
     * fetching the metadata for the referenced object. Returns null if no title
     * can be resolved.
     */
    private String resolveShortTitle(ResultType res) {
        // Prefer the title already present in the search result
        try {
            var titles = res.getObject().getGeneric().getProvided().getTitle();
            if (titles != null && !titles.isEmpty()) {
                return titles.get(0);
            }
        } catch (Exception e) {
            // fall through to metadata lookup
        }

        // Fall back to fetching the metadata of the referenced object
        try {
            var meta = searchClient.infoQuery().getMetadata(res.getTextgridUri());
            if (meta != null && meta.getResult() != null && !meta.getResult().isEmpty()) {
                var titles = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle();
                if (titles != null && !titles.isEmpty()) {
                    return titles.get(0);
                }
            }
        } catch (Exception e) {
            // Metadata unavailable, shortTitle stays null
        }
        return null;
    }


    @Inject
    SearchClientService searchClient;

    @ConfigProperty(name = "textgrid.host")
    String textgridHost;

    @ConfigProperty(name = "service.url")
    String serviceUrl;

    @ConfigProperty(name = "validator.url")
    String validatorUrl;

    @ConfigProperty(name = "quarkus.application.version")
    String version;

    @Inject
    LaunchMode launchMode;

    @Inject
    FieldOverrideService fieldOverrideService;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/manifests/{id}")
    @Operation(summary = "Generate a TextAPI v2 manifest", description = "Use TextGridURI to list all items of an aggregation as TextAPI v2 Items")
    @APIResponse(responseCode = "200", description = "The requested Manifest", content = @Content(schema = @Schema(implementation = Manifest.class)))
    @APIResponse(responseCode = "404", description = "Not Found")
    @APIResponse(responseCode = "500", description = "Internal Server Error")
    public Manifest manifest(
            @Parameter(description = "the TextGridURI to generate a manifest for", example = "textgrid:wp1j.0")
            @PathParam("id") String id,
            @Parameter(description = "embed full resources instead of references (default: false)", example = "false")
            @QueryParam("embedded") Boolean embedded) {

        var meta = searchClient.infoQuery().getMetadata(id);
        if (meta.getResult().isEmpty()) {
            throw new jakarta.ws.rs.WebApplicationException(
                "Resource not found: " + id,
                jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR
            );
        }
        var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
        var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();

        String manifestId = serviceUrl + "/v2/manifests/" + id;
        String revision = "latest";
        // fallback, only used when the tgsearch metadata has no generated timestamps
        String timestamp = java.time.Instant.now().toString();

        // Extract license from EditionType and map to SPDX
        var object = meta.getResult().get(0).getObject();
        String licenseSpdx = "CC0-1.0";  // Default public domain license
        if (object.getEdition() != null && object.getEdition().getLicense() != null) {
            licenseSpdx = LicenseMapper.mapToSpdx(object.getEdition().getLicense());
            // Fall back to default if mapping returns null
            if (licenseSpdx == null) {
                licenseSpdx = "CC0-1.0";
            }
        }

        var m = new Manifest()
                .setContext("https://editions.sub.uni-goettingen.de/context.json")
                .setTextapiVersion("2.0.0")
                .setId(manifestId)
                .setTextapiType("TextApiManifest")
                .setRevision(revision)
                .setCreated(getCreatedTimestamp(object, timestamp))
                .setModified(getModifiedTimestamp(object, timestamp))
                .setLicense(licenseSpdx)
                .setTotal(0);

        // Add title from metadata
        m.getTitles().add(label);

        // Add agents from metadata (EditionType or WorkType)
        if (object.getEdition() != null) {
            for (AgentType tgAgent : object.getEdition().getAgent()) {
                var agent = new Agent()
                        .setName(tgAgent.getValue())
                        .addRole(tgAgent.getRole() != null ? tgAgent.getRole().value() : "unknown");
                if (tgAgent.getId() != null) {
                    agent.setId(tgAgent.getId());
                }
                m.addAgent(agent);
            }
        } else if (object.getWork() != null) {
            for (AgentType tgAgent : object.getWork().getAgent()) {
                var agent = new Agent()
                        .setName(tgAgent.getValue())
                        .addRole(tgAgent.getRole() != null ? tgAgent.getRole().value() : "unknown");
                if (tgAgent.getId() != null) {
                    agent.setId(tgAgent.getId());
                }
                m.addAgent(agent);
            }
        }

        // For aggregations, add all items
        if (isAggregationFormat(format)) {
            for (ResultType res : searchClient.navigationQuery().listAggregation(id).getResult()) {
                var itemId = serviceUrl + "/v2/items/" + res.getTextgridUri();
                var itemLabel = res.getObject().getGeneric().getProvided().getTitle().get(0);
                var item = new Item()
                        .setContext("https://editions.sub.uni-goettingen.de/context.json")
                        .setTextapiVersion("2.0.0")
                        .setId(itemId)
                        .setTextapiType("TextApiItem")
                        .setRevision(revision)
                        .setCreated(getCreatedTimestamp(res.getObject(), timestamp))
                        .setModified(getModifiedTimestamp(res.getObject(), timestamp))
                        .setTextItemType("section")
                        .setDivision("full text")
                        .addLanguage("und");

                // Add title to item
                item.getTitles().add(itemLabel);

                // Add content pointing to aggregator
                var content = new info.textgrid.services.textapi.apiv2.Content()
                        .setTexttextgridHost(textgridHost)
                        .setTextgridUri(res.getTextgridUri())
                        .setId(textgridHost + "/1.0/aggregator/html/" + res.getTextgridUri() + "?embedded=true")
                        .setContentType("text/html");
                item.addContent(content);

                m.addItem(item);
            }
            m.setTotal(m.getItems().size());
        } else {
            // Single item
            var itemId = serviceUrl + "/v2/items/" + id;
            var item = new Item()
                    .setContext("https://editions.sub.uni-goettingen.de/context.json")
                    .setTextapiVersion("2.0.0")
                    .setId(itemId)
                    .setTextapiType("TextApiItem")
                    .setRevision(revision)
                    .setCreated(getCreatedTimestamp(object, timestamp))
                    .setModified(getModifiedTimestamp(object, timestamp))
                    .setTextItemType("full")
                    .setDivision("full text")
                    .addLanguage("und");

            // Add title to item
            item.getTitles().add(label);

            // Add content pointing to aggregator
            var content = new info.textgrid.services.textapi.apiv2.Content()
                    .setTexttextgridHost(textgridHost)
                    .setTextgridUri(id)
                    .setId(textgridHost + "/1.0/aggregator/html/" + id + "?embedded=true")
                    .setContentType("text/html");
            item.addContent(content);

            m.addItem(item);
            m.setTotal(1);
        }

        // Extract project ID and apply field overrides
        String projectId = extractProjectId(object);
        if (projectId != null) {
            applyFieldOverrides(projectId, m, null);

            // Apply include and exclude rules for items.
            // 1) Include filter: Keep only items that appear in include targets.
            // 2) Exclude filter: Remove items that appear in exclude targets.
            var config = fieldOverrideService.loadConfig(projectId);
            if (config != null && m.getItems() != null) {
                if (config.getInclude() != null) {
                    m.getItems().removeIf(item -> {
                        String itemId = item.getId();
                        return itemId != null
                                && !fieldOverrideService.isIncluded(config, manifestId, itemId);
                    });
                }
                if (config.getExclude() != null) {
                    m.getItems().removeIf(item -> {
                        String itemId = item.getId();
                        return itemId != null
                                && fieldOverrideService.isExcluded(config, manifestId, itemId);
                    });
                }
                // Recompute total after filtering
                m.setTotal(m.getItems().size());
            }
        }

        return m;
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/items/{id}")
    @Operation(summary = "Generate a TextAPI v2 item", description = "Get aggregator URL for given TextGridUri for TEI -> HTML transformation")
    @APIResponse(responseCode = "200", description = "The requested Item", content = @Content(schema = @Schema(implementation = Item.class)))
    @APIResponse(responseCode = "404", description = "Not Found")
    @APIResponse(responseCode = "500", description = "Internal Server Error")
    public Item item(
            @Parameter(description = "textgridUri of a single item", example = "textgrid:wmd3.0")
            @PathParam("id") String id) {

        var meta = searchClient.infoQuery().getMetadata(id);
        if (meta.getResult().isEmpty()) {
            throw new jakarta.ws.rs.WebApplicationException(
                "Resource not found: " + id,
                jakarta.ws.rs.core.Response.Status.NOT_FOUND
            );
        }
        var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);

        String itemId = serviceUrl + "/v2/items/" + id;
        String revision = "latest";
        // fallback, only used when the tgsearch metadata has no generated timestamps
        String timestamp = java.time.Instant.now().toString();

        var content = new info.textgrid.services.textapi.apiv2.Content()
                .setTexttextgridHost(textgridHost)
                .setTextgridUri(id)
                .setId(textgridHost + "/1.0/aggregator/html/" + id + "?embedded=true")
                .setContentType("text/html");

        var item = new Item()
                .setContext("https://editions.sub.uni-goettingen.de/context.json")
                .setTextapiVersion("2.0.0")
                .setId(itemId)
                .setTextapiType("TextApiItem")
                .setRevision(revision)
                .setCreated(getCreatedTimestamp(meta.getResult().get(0).getObject(), timestamp))
                .setModified(getModifiedTimestamp(meta.getResult().get(0).getObject(), timestamp))
                .setTextItemType("full")
                .setDivision("full text")
                .addLanguage("und")
                .addContent(content);

        // Add title to item
        item.getTitles().add(label);

        // Apply field overrides if available
        try {
            String projectId = extractProjectId(meta.getResult().get(0).getObject());
            if (projectId != null) {
                fieldOverrideService.loadAndApplyItemOverrides(projectId, item, null);
            }
        } catch (Exception e) {
            // Field overrides are optional, so log and continue
            // In production, consider proper logging
        }

        return item;
    }

    @HEAD
    @Path("/manifests/{id}")
    @Operation(summary = "Check if manifest exists")
    public Response headManifest(
            @Parameter(description = "the TextGridURI to check", example = "textgrid:wp1j.0")
            @PathParam("id") String id) {
        // Check if the resource exists by attempting to fetch metadata
        try {
            var meta = searchClient.infoQuery().getMetadata(id);
            if (meta.getResult().isEmpty()) {
                return Response.status(Response.Status.NOT_FOUND).build();
            }
            return Response.ok().build();
        } catch (Exception e) {
            return Response.status(Response.Status.NOT_FOUND).build();
        }
    }

    @HEAD
    @Path("/items/{id}")
    @Operation(summary = "Check if item exists")
    public Response headItem(
            @Parameter(description = "textgridUri to check", example = "textgrid:wmd3.0")
            @PathParam("id") String id) {
        try {
            var meta = searchClient.infoQuery().getMetadata(id);
            if (meta.getResult().isEmpty()) {
                return Response.status(Response.Status.NOT_FOUND).build();
            }
            return Response.ok().build();
        } catch (Exception e) {
            return Response.status(Response.Status.NOT_FOUND).build();
        }
    }

    @OPTIONS
    @Path("/manifests/{id}")
    @Operation(summary = "Get options for manifest endpoint")
    public Response optionsManifest() {
        return Response.ok()
                .header("Allow", "GET, HEAD, OPTIONS")
                .build();
    }

    @OPTIONS
    @Path("/items/{id}")
    @Operation(summary = "Get options for item endpoint")
    public Response optionsItem() {
        return Response.ok()
                .header("Allow", "GET, HEAD, OPTIONS")
                .build();
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/collections/{slug}/")
    @Operation(summary = "Retrieve a Collection", description = "Retrieve a TextAPI v2 Collection from a TextGrid aggregation URI")
    @APIResponse(responseCode = "200", description = "The requested Collection", content = @Content(schema = @Schema(implementation = Collection.class)))
    @APIResponse(responseCode = "404", description = "Not Found")
    @APIResponse(responseCode = "400", description = "Bad Request - URI is not an aggregation")
    @APIResponse(responseCode = "500", description = "Internal Server Error")
    public Collection collection(
            @Parameter(description = "the TextGrid aggregation URI to generate a collection for", example = "textgrid:3r84g.0")
            @PathParam("slug") String slug,
            @Parameter(description = "embed full resources instead of references (default: false)", example = "false")
            @QueryParam("embedded") Boolean embedded) {

        var meta = searchClient.infoQuery().getMetadata(slug);
        if (meta.getResult().isEmpty()) {
            throw new jakarta.ws.rs.WebApplicationException(
                "Resource not found: " + slug,
                jakarta.ws.rs.core.Response.Status.NOT_FOUND
            );
        }
        var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
        var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();
        var project = meta.getResult().get(0).getObject().getGeneric().getGenerated().getProject();
        var dataContributor = meta.getResult().get(0).getObject().getGeneric().getGenerated().getDataContributor();
        var created = meta.getResult().get(0).getObject().getGeneric().getGenerated().getCreated();
        var modified = meta.getResult().get(0).getObject().getGeneric().getGenerated().getLastModified();

        // Verify it's an aggregation
        if (!isAggregationFormat(format)) {
            throw new jakarta.ws.rs.WebApplicationException(
                "URI " + slug + " is not a valid aggregation format, format is: " + format,
                jakarta.ws.rs.core.Response.Status.BAD_REQUEST
            );
        }

        String collectionId = serviceUrl + "/v2/collections/" + slug + "/";
        String revision = "latest";
        // fallback, only used when the tgsearch metadata has no generated timestamps
        String timestamp = java.time.Instant.now().toString();

        var c = new Collection()
                .setContext("https://editions.sub.uni-goettingen.de/context.json")
                .setTextapiVersion("2.0.0")
                .setId(collectionId)
                .setTextapiType("TextApiCollection")
                .setRevision(revision)
                .setCreated(created != null ? created.toString() : timestamp)
                .setModified(modified != null ? modified.toString() : timestamp);

        // Add title from metadata
        c.getTitles().add(label);

        // Add collector (prefer project data, fallback to dataContributor)
        // Decode URL-encoded characters in dataContributor (e.g., %C3%B6 -> รถ)
        String decodedDataContributor = null;
        if (dataContributor != null) {
            try {
                decodedDataContributor = URLDecoder.decode(dataContributor, StandardCharsets.UTF_8);
            } catch (IllegalArgumentException e) {
                // If decoding fails, use the original value
                decodedDataContributor = dataContributor;
            }
        }
        var collector = new Agent()
                .setName(decodedDataContributor != null ? decodedDataContributor :  project != null ? project.getId() : "Unknown")
                .addRole("collector");
        c.getCollectors().add(collector);

        // Get list of aggregated URIs
        var results = searchClient.navigationQuery().listAggregation(slug).getResult();

        // Create manifest references or full manifests based on embedded parameter
        if (Boolean.TRUE.equals(embedded)) {
            // Embed full manifests
            for (ResultType res : results) {
                var manifestId = res.getTextgridUri();
                var manifestObj = searchClient.infoQuery().getMetadata(manifestId);
                var manifestLabel = manifestObj.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
                var manifestFormat = manifestObj.getResult().get(0).getObject().getGeneric().getProvided().getFormat();

                String manifestUrl = serviceUrl + "/v2/manifests/" + manifestId;

                // Extract license from EditionType and map to SPDX for embedded manifest
                String embeddedLicenseSpdx = "CC0-1.0";  // Default public domain license
                var manifestMeta = searchClient.infoQuery().getMetadata(manifestId);
                var manifestObject = manifestMeta.getResult().get(0).getObject();
                if (manifestObject.getEdition() != null && manifestObject.getEdition().getLicense() != null) {
                    embeddedLicenseSpdx = LicenseMapper.mapToSpdx(manifestObject.getEdition().getLicense());
                    if (embeddedLicenseSpdx == null) {
                        embeddedLicenseSpdx = "CC0-1.0";
                    }
                }

                var manifest = new Manifest()
                        .setContext("https://editions.sub.uni-goettingen.de/context.json")
                        .setTextapiVersion("2.0.0")
                        .setId(manifestUrl)
                        .setTextapiType("TextApiManifest")
                        .setRevision(revision)
                        .setCreated(getCreatedTimestamp(manifestObject, timestamp))
                        .setModified(getModifiedTimestamp(manifestObject, timestamp))
                        .setLicense(embeddedLicenseSpdx)
                        .setTotal(0);

                // Add title to manifest
                manifest.getTitles().add(manifestLabel);

                // For aggregations, add all items
                if (isAggregationFormat(manifestFormat)) {
                    for (ResultType itemRes : searchClient.navigationQuery().listAggregation(manifestId).getResult()) {
                        var itemId = serviceUrl + "/v2/items/" + itemRes.getTextgridUri();
                        var itemLabel = itemRes.getObject().getGeneric().getProvided().getTitle().get(0);
                        var item = new Item()
                                .setContext("https://editions.sub.uni-goettingen.de/context.json")
                                .setTextapiVersion("2.0.0")
                                .setId(itemId)
                                .setTextapiType("TextApiItem")
                                .setRevision(revision)
                                .setCreated(getCreatedTimestamp(itemRes.getObject(), timestamp))
                                .setModified(getModifiedTimestamp(itemRes.getObject(), timestamp))
                                .setTextItemType("section")
                                .setDivision("full text")
                                .addLanguage("und");

                        // Add title to item
                        item.getTitles().add(itemLabel);

                        // Add content pointing to aggregator
                        var content = new info.textgrid.services.textapi.apiv2.Content()
                                .setTexttextgridHost(textgridHost)
                                .setTextgridUri(itemRes.getTextgridUri())
                                .setId(textgridHost + "/1.0/aggregator/html/" + itemRes.getTextgridUri() + "?embedded=true")
                                .setContentType("text/html");
                        item.addContent(content);

                        manifest.addItem(item);
                    }
                    manifest.setTotal(manifest.getItems().size());
                } else {
                    // Single item
                    var itemId = serviceUrl + "/v2/items/" + manifestId;
                    var item = new Item()
                            .setContext("https://editions.sub.uni-goettingen.de/context.json")
                            .setTextapiVersion("2.0.0")
                            .setId(itemId)
                            .setTextapiType("TextApiItem")
                            .setRevision(revision)
                            .setCreated(getCreatedTimestamp(manifestObject, timestamp))
                            .setModified(getModifiedTimestamp(manifestObject, timestamp))
                            .setTextItemType("full")
                            .setDivision("full text")
                            .addLanguage("und");

                    // Add title to item
                    item.getTitles().add(manifestLabel);

                    // Add content pointing to aggregator
                    var content = new info.textgrid.services.textapi.apiv2.Content()
                            .setTexttextgridHost(textgridHost)
                            .setTextgridUri(manifestId)
                            .setId(textgridHost + "/1.0/aggregator/html/" + manifestId + "?embedded=true")
                            .setContentType("text/html");
                    item.addContent(content);

                    manifest.addItem(item);
                    manifest.setTotal(1);
                }

                c.addManifest(manifest);
            }
        } else {
            // Create manifest references (id + shortTitle) for each aggregated URI
            for (ResultType res : results) {
                var manifestRef = new SimpleReference()
                        .setId(serviceUrl + "/v2/manifests/" + res.getTextgridUri())
                        .setShortTitle(resolveShortTitle(res));
                c.addManifest(manifestRef);
            }
        }

        // Set total count
        c.setTotal(c.getManifests().size());

        // Apply field override config: annotationCollection, include and exclude rules.
        // The config maps keys to annotation collection URLs: a TextGrid URI key
        // applies to that specific collection; the "project" key applies only to the
        // project collection endpoint.
        if (project != null) {
            // Get the Project ID string, which is the key used to locate the config file
            String projectId = extractProjectId(meta.getResult().get(0).getObject());
            if (projectId != null) {
                var config = fieldOverrideService.loadConfig(projectId);
                if (config != null) {
                    if (config.getAnnotationCollection() != null) {
                        // Only apply when this TextGrid URI (slug) is used as a key
                        String annotationCollectionUrl = config.getAnnotationCollection().get(slug);
                        if (annotationCollectionUrl != null && !annotationCollectionUrl.isEmpty()) {
                            c.setAnnotationCollection(annotationCollectionUrl);
                        }
                    }
                    // Apply include and exclude rules for manifests.
                    // 1) Include filter: Keep only manifests that appear in include targets.
                    // 2) Exclude filter: Remove manifests that appear in exclude targets.
                    if ((config.getInclude() != null || config.getExclude() != null) && c.getManifests() != null) {
                        if (config.getInclude() != null) {
                            c.getManifests().removeIf(entry -> {
                                String entryId;
                                if (entry instanceof Manifest embeddedManifest) {
                                    entryId = embeddedManifest.getId();
                                } else if (entry instanceof SimpleReference manifestRef) {
                                    entryId = manifestRef.getId();
                                } else {
                                    entryId = String.valueOf(entry);
                                }
                                return entryId != null
                                        && !fieldOverrideService.isIncluded(config, collectionId, entryId);
                            });
                        }
                        if (config.getExclude() != null) {
                            c.getManifests().removeIf(entry -> {
                                String entryId;
                                if (entry instanceof Manifest embeddedManifest) {
                                    entryId = embeddedManifest.getId();
                                } else if (entry instanceof SimpleReference manifestRef) {
                                    entryId = manifestRef.getId();
                                } else {
                                    entryId = String.valueOf(entry);
                                }
                                return entryId != null
                                        && fieldOverrideService.isExcluded(config, collectionId, entryId);
                            });
                        }
                        // Recompute total after filtering
                        c.setTotal(c.getManifests().size());
                    }
                }
            }
        }

        return c;
    }

    @HEAD
    @Path("/collections/{slug}/")
    @Operation(summary = "Check if collection exists")
    public Response headCollection(
            @Parameter(description = "the TextGrid aggregation URI to check", example = "textgrid:3r84g.0")
            @PathParam("slug") String slug) {
        try {
            var meta = searchClient.infoQuery().getMetadata(slug);
            var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();

            // Verify it's an aggregation
            if (!isAggregationFormat(format)) {
                return Response.status(Response.Status.BAD_REQUEST).build();
            }
            return Response.ok().build();
        } catch (Exception e) {
            return Response.status(Response.Status.NOT_FOUND).build();
        }
    }

    @OPTIONS
    @Path("/collections/{slug}/")
    @Operation(summary = "Get options for collection endpoint")
    public Response optionsCollection() {
        return Response.ok()
                .header("Allow", "GET, HEAD, OPTIONS")
                .build();
    }

    /**
     * Retrieve a collection of collections (and editions) for a specific project.
     * Accepts a project ID starting with 'TGPR-' and returns all collection and
     * edition aggregations belonging to that project as a TextAPI v2 Collection.
     */
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/collections/project/{projectId}/")
    @Operation(summary = "Retrieve a Project Collection", description = "Retrieve a TextAPI v2 Collection containing all collections and editions for a specific TextGrid project")
    @APIResponse(responseCode = "200", description = "The requested Project Collection", content = @Content(schema = @Schema(implementation = Collection.class)))
    @APIResponse(responseCode = "400", description = "Bad Request - Invalid project ID format")
    @APIResponse(responseCode = "404", description = "Not Found - Project not found")
    @APIResponse(responseCode = "500", description = "Internal Server Error")
    public Collection projectCollection(
            @Parameter(description = "the TextGrid project ID (must start with TGPR-)", example = "TGPR-d862e14d-4df7-052b-00fe-661cb242231c")
            @PathParam("projectId") String projectId) {

        // Validate project ID format
        if (projectId == null || !projectId.startsWith("TGPR-")) {
            throw new jakarta.ws.rs.WebApplicationException(
                "Project ID must start with 'TGPR-'",
                jakarta.ws.rs.core.Response.Status.BAD_REQUEST
            );
        }

        // The project collection is virtual (assembled from all of a project's
        // toplevel aggregations). Its "created" timestamp is the oldest "created"
        // value and its "modified" timestamp the youngest "lastModified" value of
        // the sibling resources; current time is used only as fallback.
        String timestamp = java.time.Instant.now().toString();
        String collectionId = serviceUrl + "/v2/collections/project/" + projectId + "/";

        // Create the collection
        var c = new Collection()
                .setContext("https://editions.sub.uni-goettingen.de/context.json")
                .setTextapiVersion("2.0.0")
                .setId(collectionId)
                .setTextapiType("TextApiCollection")
                .setRevision("latest");

        // Track the oldest "created" and the youngest "lastModified" value of the
        // sibling resources; they provide the timestamps of the virtual collection
        XMLGregorianCalendar oldestCreated = null;
        XMLGregorianCalendar youngestLastModified = null;

        // Get toplevel aggregations with pagination (1000 items per page should be enough)
        int pageSize = 1000;
        int start = 0;
        boolean hasMore = true;

        // The project title is read from the metadata of the first sibling
        // resource: the text node of the "project" child node of the
        // "generated" element (see below). "Project <projectId>" is only a
        // fallback, used when the metadata does not provide a project name.
        String projectTitle = "Project " + projectId;

        // Add collector using project ID
        c.getCollectors().add(new Agent().setName(projectId).addRole("collector"));

        // Fetch all pages of toplevel aggregations
        while (hasMore) {
            try {
                var toplevelResponse = searchClient.projectQuery().listToplevelAggregations(projectId, start, pageSize);
                if (toplevelResponse == null || toplevelResponse.getResult() == null) {
                    break;
                }

                var results = toplevelResponse.getResult();

                // The project title is read from the metadata of the first
                // sibling resource: the text node of the "project" child node
                // of the "generated" element. Only the first metadata is used.
                if (!results.isEmpty()) {
                    String projectName = getProjectName(results.get(0).getObject());
                    if (projectName != null) {
                        projectTitle = projectName;
                    }
                }

                for (ResultType res : results) {
                    String uri = res.getTextgridUri();
                    String format = res.getObject().getGeneric().getProvided().getFormat();
                    if (format == null) {
                        continue;
                    }

                    // Track the oldest "created" and the youngest "lastModified" of
                    // the sibling resources ("generated" element) for the timestamps
                    var generated = res.getObject().getGeneric().getGenerated();
                    if (generated != null) {
                        if (generated.getCreated() != null && (oldestCreated == null
                                || generated.getCreated().compare(oldestCreated) < 0)) {
                            oldestCreated = generated.getCreated();
                        }
                        if (generated.getLastModified() != null && (youngestLastModified == null
                                || generated.getLastModified().compare(youngestLastModified) > 0)) {
                            youngestLastModified = generated.getLastModified();
                        }
                    }

                    // text/tg.collection+tg.aggregation+xml -> add as sub-collection
                    if (format.equals("text/tg.collection+tg.aggregation+xml")) {
                        var collectionRef = new CollectionReference()
                                .setId(serviceUrl + "/v2/collections/" + uri + "/")
                                .setShortTitle(resolveShortTitle(res));
                        c.addCollection(collectionRef);
                    }
                    // text/tg.edition+tg.aggregation+xml -> add as manifest
                    else if (format.equals("text/tg.edition+tg.aggregation+xml")) {
                        var manifestRef = new SimpleReference()
                                .setId(serviceUrl + "/v2/manifests/" + uri)
                                .setShortTitle(resolveShortTitle(res));
                        c.addManifest(manifestRef);
                    }
                    // All other formats are ignored as per requirements
                }

                // Check if we need another page
                String hits = toplevelResponse.getHits();
                int totalFound = hits != null ? Integer.parseInt(hits) : 0;
                start += results.size();
                hasMore = start < totalFound;

                if (results.isEmpty()) {
                    hasMore = false;
                }
            } catch (Exception e) {
                throw new jakarta.ws.rs.WebApplicationException(
                    "Project not found: " + projectId,
                    jakarta.ws.rs.core.Response.Status.NOT_FOUND
                );
            }
        }

        // Add the project title read from the metadata of the first sibling resource
        c.getTitles().add(projectTitle);

        // Set the collection's timestamps from the sibling resources:
        // the oldest "created" and the youngest "lastModified" value
        c.setCreated(oldestCreated != null ? oldestCreated.toString() : timestamp);
        c.setModified(youngestLastModified != null ? youngestLastModified.toString() : timestamp);

        // Set total count (collections + manifests)
        int total = 0;
        if (c.getCollections() != null) {
            total += c.getCollections().size();
        }
        if (c.getManifests() != null) {
            total += c.getManifests().size();
        }
        c.setTotal(total);

        // Apply field override config: annotationCollection, include and exclude rules
        var config = fieldOverrideService.loadConfig(projectId);
        if (config != null) {
            if (config.getAnnotationCollection() != null) {
                String annotationCollectionUrl = config.getAnnotationCollection().get("project");
                if (annotationCollectionUrl != null && !annotationCollectionUrl.isEmpty()) {
                    c.setAnnotationCollection(annotationCollectionUrl);
                }
            }

            // Apply include rules for project collection: when present, replace the list
            // with the exact URLs from the include target (in the order provided).
            if (config.getInclude() != null) {
                List<String> includeTargets = fieldOverrideService.getIncludeTargetUrls(config, collectionId);
                if (includeTargets != null && !includeTargets.isEmpty()) {
                    // Clear existing collections and manifests
                    c.setCollections(new java.util.ArrayList<>());
                    c.setManifests(new java.util.ArrayList<>());

                    // Add entries based on the include target list
                    for (String targetUrl : includeTargets) {
                        if (targetUrl == null) {
                            continue;
                        }
                        try {
                            // Extract the TextGrid URI from the target URL
                            // Expected format: <base>/textapi/v2/collections/<uri>/ or <base>/textapi/v2/manifests/<uri>
                            String uri = null;
                            boolean isCollection = true;

                            String normalized = targetUrl.trim();
                            while (normalized.endsWith("/")) {
                                normalized = normalized.substring(0, normalized.length() - 1);
                            }
                            int slash = normalized.lastIndexOf('/');
                            if (slash >= 0) {
                                uri = normalized.substring(slash + 1);
                                // Check if this is a collection or manifest URL
                                if (normalized.contains("/v2/collections/")) {
                                    isCollection = true;
                                } else if (normalized.contains("/v2/manifests/")) {
                                    isCollection = false;
                                } else {
                                    // Fallback: assume collection if not clearly specified
                                    isCollection = true;
                                }
                            }

                            if (uri == null) {
                                continue;
                            }

                            // Fetch metadata for short title
                            String shortTitle = uri;
                            try {
                                var meta = searchClient.infoQuery().getMetadata(uri);
                                if (meta != null && meta.getResult() != null && !meta.getResult().isEmpty()) {
                                    var res = meta.getResult().get(0);
                                    shortTitle = resolveShortTitle(res);
                                }
                            } catch (Exception e) {
                                // Use URI as fallback title
                            }

                            if (isCollection) {
                                var collectionRef = new CollectionReference()
                                        .setId(targetUrl)
                                        .setShortTitle(shortTitle);
                                c.addCollection(collectionRef);
                            } else {
                                var manifestRef = new SimpleReference()
                                        .setId(targetUrl)
                                        .setShortTitle(shortTitle);
                                c.addManifest(manifestRef);
                            }
                        } catch (Exception e) {
                            // Skip problematic entries
                        }
                    }
                }
            }

            // Apply exclude rules: remove target IDs from the lists if this
            // collection's ID matches a rule's "from"
            if (config.getExclude() != null) {
                if (c.getCollections() != null) {
                    c.getCollections().removeIf(entry ->
                            entry.getId() != null
                                    && fieldOverrideService.isExcluded(config, collectionId, entry.getId()));
                }
                if (c.getManifests() != null) {
                    c.getManifests().removeIf(entry -> {
                        String entryId;
                        if (entry instanceof Manifest embeddedManifest) {
                            entryId = embeddedManifest.getId();
                        } else if (entry instanceof SimpleReference manifestRef) {
                            entryId = manifestRef.getId();
                        } else {
                            entryId = String.valueOf(entry);
                        }
                        return entryId != null
                                && fieldOverrideService.isExcluded(config, collectionId, entryId);
                    });
                }
                // Recompute total after removals
                int adjustedTotal = 0;
                if (c.getCollections() != null) {
                    adjustedTotal += c.getCollections().size();
                }
                if (c.getManifests() != null) {
                    adjustedTotal += c.getManifests().size();
                }
                c.setTotal(adjustedTotal);
            }
        }

        return c;
    }

    @HEAD
    @Path("/collections/project/{projectId}/")
    @Operation(summary = "Check if project collection exists")
    public Response headProjectCollection(
            @Parameter(description = "the TextGrid project ID (must start with TGPR-)", example = "TGPR-d862e14d-4df7-052b-00fe-661cb242231c")
            @PathParam("projectId") String projectId) {

        // Validate project ID format
        if (projectId == null || !projectId.startsWith("TGPR-")) {
            return Response.status(Response.Status.BAD_REQUEST).build();
        }

        try {
            var toplevelResponse = searchClient.projectQuery().listToplevelAggregations(projectId, 0, 1);
            if (toplevelResponse != null && toplevelResponse.getResult() != null && !toplevelResponse.getResult().isEmpty()) {
                return Response.ok().build();
            }
            return Response.status(Response.Status.NOT_FOUND).build();
        } catch (Exception e) {
            return Response.status(Response.Status.NOT_FOUND).build();
        }
    }

    @OPTIONS
    @Path("/collections/project/{projectId}/")
    @Operation(summary = "Get options for project collection endpoint")
    public Response optionsProjectCollection() {
        return Response.ok()
                .header("Allow", "GET, HEAD, OPTIONS")
                .build();
    }


    @GET
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/info")
    @Operation(summary = "Build info & config of this service")
    public String info() {
        return """
                tg-textapi v2 %s
                textgrid host: %s
                devmode: %s
                """.formatted(version, textgridHost, launchMode.isDevOrTest());
    }

}