FieldOverrideService.java

package info.textgrid.services.textapi;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import info.textgrid.services.textapi.apiv2.Manifest;
import info.textgrid.services.textapi.apiv2.Item;

/**
 * Service for applying field overrides based on YAML configuration files.
 * Configuration files are named after the projectId and located in a configurable directory.
 */
@ApplicationScoped
public class FieldOverrideService {

    private static final String DEFAULT_CONFIG_DIR = "config/overrides";

    private String configDir = "config/overrides";
    private YAMLMapper yamlMapper;

    @Inject
    Config config;

    @PostConstruct
    public void init() {
        // Read config directory from config property if available
        String configDirValue = config.getOptionalValue("field.overrides.config-dir", String.class).orElse("config/overrides");
        this.configDir = configDirValue;

        // Ensure config directory exists
        Path dirPath = Paths.get(configDir);
        try {
            if (!Files.exists(dirPath)) {
                Files.createDirectories(dirPath);
            }
        } catch (IOException e) {
            throw new RuntimeException("Failed to create config directory: " + configDir, e);
        }

        // Initialize YAML mapper
        YAMLFactory yamlFactory = YAMLFactory.builder()
            .enable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
            .build();
        this.yamlMapper = new YAMLMapper(yamlFactory);
        // Allow scalar values (e.g. a single target URL) to be deserialized into lists
        this.yamlMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
    }

    /**
     * Extract the identifying segment (last non-empty path segment) from an ID or URL.
     * E.g. "https://host/v2/collections/textgrid:48vn1.3/" -> "textgrid:48vn1.3"
     * or "textgrid:48vn1.3" -> "textgrid:48vn1.3".
     * This makes matching independent of host names and trailing slashes.
     */
    private static String idSegment(String idOrUrl) {
        if (idOrUrl == null) {
            return null;
        }
        String trimmed = idOrUrl.trim();
        while (trimmed.endsWith("/")) {
            trimmed = trimmed.substring(0, trimmed.length() - 1);
        }
        int slash = trimmed.lastIndexOf('/');
        return slash >= 0 ? trimmed.substring(slash + 1) : trimmed;
    }

    /**
     * Check whether an entry satisfies the "include" rules for the resource with the
     * given ID. When an include rule matches, only entries whose ID appears in the
     * rule's "target" list are retained; all others are filtered out.
     *
     * Matching is done on the identifying last path segment of the IDs/URLs, so
     * host names and trailing slashes do not matter.
     *
     * @param config the configuration containing the include rules
     * @param currentResourceId the ID of the resource whose list is being built
     * @param entryIdOrUrl the list entry candidate (ID or URL)
     * @return true if the entry satisfies the include rules; also returns true
     *         when no include rule applies to currentResourceId (include does not imply deny-by-default)
     */
    public boolean isIncluded(ProjectFieldConfig config, String currentResourceId, String entryIdOrUrl) {
        if (config == null || config.getInclude() == null
                || currentResourceId == null || entryIdOrUrl == null) {
            return true;
        }
        String currentSegment = idSegment(currentResourceId);
        String entrySegment = idSegment(entryIdOrUrl);
        if (currentSegment == null || entrySegment == null) {
            return true;
        }
        for (Include rule : config.getInclude()) {
            if (rule == null || rule.getIn() == null) {
                continue;
            }
            // The rule only applies to the resource identified by "in"
            if (!currentSegment.equals(idSegment(rule.getIn()))) {
                continue;
            }
            if (rule.getTarget() == null) {
                continue;
            }
            // An entry is included if it matches any target in the rule
            for (String target : rule.getTarget()) {
                if (target != null && entrySegment.equals(idSegment(target))) {
                    return true;
                }
            }
            // If a rule applies to this resource but the entry is not in any target, it is excluded
            return false;
        }
        // No include rule applies; entry is included
        return true;
    }

    /**
     * Check whether an entry should be removed from the list of the resource with the
     * given ID, according to the "exclude" rules in the configuration.
     *
     * Matching is done on the identifying last path segment of the IDs/URLs, so
     * host names and trailing slashes do not matter.
     *
     * @param config the configuration containing the exclude rules
     * @param currentResourceId the ID of the resource whose list is being built
     * @param entryIdOrUrl the list entry candidate (ID or URL)
     * @return true if the entry must be removed from the list
     */
    public boolean isExcluded(ProjectFieldConfig config, String currentResourceId, String entryIdOrUrl) {
        if (config == null || config.getExclude() == null
                || currentResourceId == null || entryIdOrUrl == null) {
            return false;
        }
        String currentSegment = idSegment(currentResourceId);
        String entrySegment = idSegment(entryIdOrUrl);
        if (currentSegment == null || entrySegment == null) {
            return false;
        }
        for (Exclude rule : config.getExclude()) {
            if (rule == null || rule.getFrom() == null) {
                continue;
            }
            // The rule only applies to the resource identified by "from"
            if (!currentSegment.equals(idSegment(rule.getFrom()))) {
                continue;
            }
            if (rule.getTarget() == null) {
                continue;
            }
            for (String target : rule.getTarget()) {
                if (target != null && entrySegment.equals(idSegment(target))) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * For the given resource ID, retrieve the full target URLs from any include rule.
     * Returns the list in the exact order provided in the configuration; null if
     * no include rule applies.
     */
    public List<String> getIncludeTargetUrls(ProjectFieldConfig config, String currentResourceId) {
        if (config == null || config.getInclude() == null || currentResourceId == null) {
            return null;
        }
        String currentSegment = idSegment(currentResourceId);
        if (currentSegment == null) {
            return null;
        }
        for (Include rule : config.getInclude()) {
            if (rule == null || rule.getIn() == null) {
                continue;
            }
            if (!currentSegment.equals(idSegment(rule.getIn()))) {
                continue;
            }
            return rule.getTarget(); // preserve order from config
        }
        return null;
    }

    /**
     * Load the YAML configuration for a specific project.
     *
     * @param projectId the project ID
     * @return the configuration or null if no config exists for this project
     */
    public ProjectFieldConfig loadConfig(String projectId) {
        String yamlFileName = projectId + ".yaml";
        Path configPath = Paths.get(configDir, yamlFileName);

        if (!Files.exists(configPath)) {
            return null;
        }

        try {
            return yamlMapper.readValue(configPath.toFile(), ProjectFieldConfig.class);
        } catch (IOException e) {
            throw new RuntimeException("Failed to load field override config for project: " + projectId, e);
        }
    }

    /**
     * Apply all field overrides to a manifest object.
     *
     * @param config the configuration containing override rules
     * @param manifest the manifest to modify
     * @param contextSlug the context slug (object name) for scope limiting
     * @return true if any changes were made
     */
    public boolean applyManifestOverrides(ProjectFieldConfig config, Manifest manifest, String contextSlug) {
        if (config == null || config.getFields() == null || config.getFields().isEmpty()) {
            return false;
        }

        boolean changed = false;

        // Check scope restrictions
        if (!shouldApplyToScope(config, contextSlug)) {
            return false;
        }

        // Apply overrides to manifest fields
        if (applyOverrideToMap(manifest, config)) {
            changed = true;
        }

        // Apply overrides to items
        if (manifest.getItems() != null) {
            for (Item item : manifest.getItems()) {
                if (applyItemOverrides(config, item, contextSlug)) {
                    changed = true;
                }
            }
        }

        return changed;
    }

    /**
     * Apply all field overrides to an item object.
     *
     * @param config the configuration containing override rules
     * @param item the item to modify
     * @param contextSlug the context slug (object name) for scope limiting
     * @return true if any changes were made
     */
    public boolean applyItemOverrides(ProjectFieldConfig config, Item item, String contextSlug) {
        if (config == null || config.getFields() == null || config.getFields().isEmpty()) {
            return false;
        }

        if (!shouldApplyToScope(config, contextSlug)) {
            return false;
        }

        return applyOverrideToMap(item, config);
    }

    /**
     * Check if the override should be applied based on scope restrictions.
     */
    private boolean shouldApplyToScope(ProjectFieldConfig config, String contextSlug) {
        if (contextSlug == null || contextSlug.isEmpty()) {
            return true;
        }

        List<String> applyOnlyTo = config.getApplyOnlyTo();
        List<String> excludeFrom = config.getExcludeFrom();

        // If applyOnlyTo is specified, only apply to those slugs
        if (applyOnlyTo != null && !applyOnlyTo.isEmpty()) {
            return applyOnlyTo.contains(contextSlug);
        }

        // If excludeFrom is specified, don't apply to those slugs
        if (excludeFrom != null && !excludeFrom.isEmpty()) {
            return !excludeFrom.contains(contextSlug);
        }

        // No restrictions, apply to all
        return true;
    }

    /**
     * Apply field overrides to a Java object (Manifest, Item, etc.).
     */
    private boolean applyOverrideToMap(Object target, ProjectFieldConfig config) {
        boolean changed = false;

        for (FieldOverride override : config.getFields()) {
            if (applySingleOverride(target, override)) {
                changed = true;
            }
        }

        return changed;
    }

    /**
     * Apply a single override to a Java object field.
     * Supports nested paths like "contents.id" or "contents[0].id"
     */
    private boolean applySingleOverride(Object target, FieldOverride override) {
        if (target == null) {
            return false;
        }

        String fieldPath = override.getField();
        
        // Check if the field path contains dots (nested path)
        if (fieldPath.contains(".")) {
            String[] pathSegments = fieldPath.split("\\.");
            return applyNestedOverride(target, override, pathSegments);
        }
        
        // Check if the field path contains array index notation
        if (fieldPath.matches(".*\\[\\d+\\].*")) {
            String[] pathSegments = fieldPath.split("\\.");
            return applyNestedOverride(target, override, pathSegments);
        }
        
        // Simple field - use the original logic
        return applySimpleFieldOverride(target, override);
    }

    /**
     * Find a field by name in a class (including superclasses).
     */
    private java.lang.reflect.Field findField(Class<?> clazz, String fieldName) {
        while (clazz != null) {
            try {
                return clazz.getDeclaredField(fieldName);
            } catch (NoSuchFieldException e) {
                clazz = clazz.getSuperclass();
            }
        }
        return null;
    }

    /**
     * Load and apply overrides for a project in one call.
     *
     * @param projectId the project ID
     * @param manifest the manifest to modify
     * @param contextSlug the context slug for scope limiting
     * @return true if any changes were made
     */
    public boolean loadAndApplyOverrides(String projectId, Manifest manifest, String contextSlug) {
        ProjectFieldConfig config = loadConfig(projectId);
        if (config == null) {
            return false;
        }
        return applyManifestOverrides(config, manifest, contextSlug);
    }

    /**
     * Load and apply overrides to an item in one call.
     *
     * @param projectId the project ID
     * @param item the item to modify
     * @param contextSlug the context slug for scope limiting
     * @return true if any changes were made
     */
    public boolean loadAndApplyItemOverrides(String projectId, Item item, String contextSlug) {
        ProjectFieldConfig config = loadConfig(projectId);
        if (config == null) {
            return false;
        }
        return applyItemOverrides(config, item, contextSlug);
    }
    
    /**
     * Apply override to a nested field path (e.g., "contents.id" or "contents[0].id").
     */
    private boolean applyNestedOverride(Object target, FieldOverride override, String[] pathSegments) {
        return applyNestedOverrideRecursive(target, override, pathSegments, 0);
    }
    
    /**
     * Recursively apply override to nested field path.
     */
    private boolean applyNestedOverrideRecursive(Object target, FieldOverride override, String[] pathSegments, int currentIndex) {
        if (target == null || currentIndex >= pathSegments.length) {
            return false;
        }
        
        // If this is the last segment, apply the override
        if (currentIndex == pathSegments.length - 1) {
            String finalField = pathSegments[currentIndex];
            // Remove array index if present from the final field
            if (finalField.matches(".*\\[\\d+\\]")) {
                finalField = finalField.substring(0, finalField.indexOf('['));
            }
            
            FieldOverride finalOverride = new FieldOverride();
            finalOverride.setField(finalField);
            finalOverride.setType(override.getType());
            finalOverride.setValue(override.getValue());
            finalOverride.setPattern(override.getPattern());
            finalOverride.setReplacement(override.getReplacement());
            
            return applySimpleFieldOverride(target, finalOverride);
        }
        
        // Navigate to the next level
        String segment = pathSegments[currentIndex];
        Object next = navigateToField(target, segment);
        
        // Special case: if we navigated to a List and didn't use array index notation
        // apply the override to all items in the list
        if (next instanceof List && !segment.matches(".*\\[\\d+\\]")) {
            List<?> list = (List<?>) next;
            boolean changed = false;
            for (Object item : list) {
                if (item != null) {
                    if (applyNestedOverrideRecursive(item, override, pathSegments, currentIndex + 1)) {
                        changed = true;
                    }
                }
            }
            return changed;
        }
        
        // Continue recursion
        return applyNestedOverrideRecursive(next, override, pathSegments, currentIndex + 1);
    }
    
    /**
     * Navigate to a field, handling arrays with index notation.
     * Returns the target object(s) for applying overrides.
     */
    private Object navigateToField(Object obj, String fieldPath) {
        if (obj == null) {
            return null;
        }
        
        // Handle array index notation: "contents[0]" -> field="contents", index=0
        String fieldName = fieldPath;
        int arrayIndex = -1;
        
        if (fieldPath.matches(".*\\[\\d+\\]")) {
            int bracketOpen = fieldPath.indexOf('[');
            fieldName = fieldPath.substring(0, bracketOpen);
            arrayIndex = Integer.parseInt(fieldPath.substring(bracketOpen + 1, fieldPath.indexOf(']')));
        }
        
        try {
            java.lang.reflect.Field field = findField(obj.getClass(), fieldName);
            if (field == null) {
                return null;
            }
            
            field.setAccessible(true);
            Object value = field.get(obj);
            
            if (arrayIndex >= 0 && value instanceof List) {
                List<?> list = (List<?>) value;
                if (arrayIndex < list.size()) {
                    return list.get(arrayIndex);
                }
                return null;
            }
            
            return value;
        } catch (IllegalAccessException e) {
            return null;
        }
    }
    
    /**
     * Apply override to a simple (flat) field.
     */
    private boolean applySimpleFieldOverride(Object target, FieldOverride override) {
        if (target == null) {
            return false;
        }

        // Use reflection to access and modify fields
        try {
            // Get the field by name
            java.lang.reflect.Field field = findField(target.getClass(), override.getField());
            if (field == null) {
                return false;
            }

            field.setAccessible(true);
            Object currentValue = field.get(target);

            switch (override.getType()) {
                case "override":
                    // Replace the field value
                    if (override.getValue() != null) {
                        field.set(target, override.getValue());
                        return true;
                    }
                    break;

                case "append":
                    // Append to an array/list
                    if (currentValue instanceof List) {
                        @SuppressWarnings("unchecked")
                        java.util.List<Object> list = (java.util.List<Object>) currentValue;
                        if (override.getValue() != null) {
                            list.add(override.getValue());
                            return true;
                        }
                    }
                    break;

                case "pattern":
                    // Apply regex pattern to string value
                    if (currentValue instanceof String) {
                        String currentValueStr = (String) currentValue;
                        String pattern = override.getPattern();
                        String replacement = override.getReplacement();

                        if (pattern != null) {
                            Pattern regex = Pattern.compile(pattern);
                            Matcher matcher = regex.matcher(currentValueStr);
                            String newValue = matcher.replaceAll(replacement != null ? replacement : "");

                            if (!newValue.equals(currentValueStr)) {
                                field.set(target, newValue);
                                return true;
                            }
                        }
                    }
                    break;
            }
        } catch (IllegalAccessException e) {
            // Field access failed
        }

        return false;
    }
}