001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.core;
020
021import java.util.ArrayList;
022import java.util.List;
023import java.util.function.Predicate;
024import java.util.regex.Pattern;
025
026/**
027 * Declarative configuration validation framework.
028 *
029 * <p>Provides a fluent API for defining validation rules for jPOS configuration properties.
030 *
031 * <p>Example usage:
032 * <pre>
033 * ConfigValidator validator = new ConfigValidator()
034 *     .required("bootstrap-servers")
035 *     .required("app-id")
036 *     .range("startup-timeout-seconds", 1, 3600)
037 *     .range("max-retries", 0, 10)
038 *     .nonEmpty("topic")
039 *     .pattern("group-id", "^[a-zA-Z0-9._-]+$")
040 *     .custom("processing-guarantee",
041 *             v -> v.equals("at_least_once") || v.equals("exactly_once"),
042 *             "must be 'at_least_once' or 'exactly_once'");
043 *
044 * validator.validate(config);
045 * </pre>
046 *
047 * @author apr
048 * @since 3.0.2
049 */
050public class ConfigValidator {
051    /** Default constructor; no instance state to initialise. */
052    public ConfigValidator() {}
053    private final List<ValidationRule> rules = new ArrayList<>();
054
055    /**
056     * Validate that a property is present and non-empty.
057     * @param key the property key
058     * @return this validator for chaining
059     */
060    public ConfigValidator required(String key) {
061        rules.add(new ValidationRule(key, config -> {
062            String value = config.get(key, null);
063            if (value == null || value.trim().isEmpty()) {
064                throw new ConfigurationException("Required property '" + key + "' is missing or empty");
065            }
066        }));
067        return this;
068    }
069
070    /**
071     * Validate that a property, if present, is non-empty.
072     * @param key the property key
073     * @return this validator for chaining
074     */
075    public ConfigValidator nonEmpty(String key) {
076        rules.add(new ValidationRule(key, config -> {
077            String value = config.get(key, null);
078            if (value != null && value.trim().isEmpty()) {
079                throw new ConfigurationException("Property '" + key + "' must not be empty");
080            }
081        }));
082        return this;
083    }
084
085    /**
086     * Validate that an integer property is within a specified range.
087     * Only validates if the property is present.
088     * @param key the property key
089     * @param min minimum value (inclusive)
090     * @param max maximum value (inclusive)
091     * @return this validator for chaining
092     */
093    public ConfigValidator range(String key, int min, int max) {
094        rules.add(new ValidationRule(key, config -> {
095            String strValue = config.get(key, null);
096            if (strValue != null && !strValue.trim().isEmpty()) {
097                try {
098                    int value = Integer.parseInt(strValue.trim());
099                    if (value < min || value > max) {
100                        throw new ConfigurationException(
101                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
102                        );
103                    }
104                } catch (NumberFormatException e) {
105                    throw new ConfigurationException("Property '" + key + "' must be an integer: " + strValue);
106                }
107            }
108        }));
109        return this;
110    }
111
112    /**
113     * Validate that a long property is within a specified range.
114     * Only validates if the property is present.
115     * @param key the property key
116     * @param min minimum value (inclusive)
117     * @param max maximum value (inclusive)
118     * @return this validator for chaining
119     */
120    public ConfigValidator rangeLong(String key, long min, long max) {
121        rules.add(new ValidationRule(key, config -> {
122            String strValue = config.get(key, null);
123            if (strValue != null && !strValue.trim().isEmpty()) {
124                try {
125                    long value = Long.parseLong(strValue.trim());
126                    if (value < min || value > max) {
127                        throw new ConfigurationException(
128                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
129                        );
130                    }
131                } catch (NumberFormatException e) {
132                    throw new ConfigurationException("Property '" + key + "' must be a long: " + strValue);
133                }
134            }
135        }));
136        return this;
137    }
138
139    /**
140     * Validate that a double property is within a specified range.
141     * Only validates if the property is present.
142     * @param key the property key
143     * @param min minimum value (inclusive)
144     * @param max maximum value (inclusive)
145     * @return this validator for chaining
146     */
147    public ConfigValidator rangeDouble(String key, double min, double max) {
148        rules.add(new ValidationRule(key, config -> {
149            String strValue = config.get(key, null);
150            if (strValue != null && !strValue.trim().isEmpty()) {
151                try {
152                    double value = Double.parseDouble(strValue.trim());
153                    if (value < min || value > max) {
154                        throw new ConfigurationException(
155                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
156                        );
157                    }
158                } catch (NumberFormatException e) {
159                    throw new ConfigurationException("Property '" + key + "' must be a double: " + strValue);
160                }
161            }
162        }));
163        return this;
164    }
165
166    /**
167     * Validate that a property matches a regex pattern.
168     * Only validates if the property is present.
169     * @param key the property key
170     * @param regex the regular expression pattern
171     * @return this validator for chaining
172     */
173    public ConfigValidator pattern(String key, String regex) {
174        Pattern compiled = Pattern.compile(regex);
175        rules.add(new ValidationRule(key, config -> {
176            String value = config.get(key, null);
177            if (value != null && !value.trim().isEmpty()) {
178                if (!compiled.matcher(value.trim()).matches()) {
179                    throw new ConfigurationException(
180                        "Property '" + key + "' does not match pattern '" + regex + "': " + value
181                    );
182                }
183            }
184        }));
185        return this;
186    }
187
188    /**
189     * Validate a property using a custom predicate.
190     * Only validates if the property is present.
191     * @param key the property key
192     * @param predicate the validation predicate (returns true if valid)
193     * @param errorMessage error message if validation fails
194     * @return this validator for chaining
195     */
196    public ConfigValidator custom(String key, Predicate<String> predicate, String errorMessage) {
197        rules.add(new ValidationRule(key, config -> {
198            String value = config.get(key, null);
199            if (value != null && !value.trim().isEmpty()) {
200                if (!predicate.test(value.trim())) {
201                    throw new ConfigurationException("Property '" + key + "' " + errorMessage + ": " + value);
202                }
203            }
204        }));
205        return this;
206    }
207
208    /**
209     * Validate that at least one of the specified properties is present.
210     * @param keys the property keys
211     * @return this validator for chaining
212     */
213    public ConfigValidator requireAtLeastOne(String... keys) {
214        rules.add(new ValidationRule("requireAtLeastOne", config -> {
215            for (String key : keys) {
216                String value = config.get(key, null);
217                if (value != null && !value.trim().isEmpty()) {
218                    return;
219                }
220            }
221            throw new ConfigurationException(
222                "At least one of these properties must be present: " + String.join(", ", keys)
223            );
224        }));
225        return this;
226    }
227
228    /**
229     * Validate that exactly one of the specified properties is present.
230     * @param keys the property keys
231     * @return this validator for chaining
232     */
233    public ConfigValidator requireExactlyOne(String... keys) {
234        rules.add(new ValidationRule("requireExactlyOne", config -> {
235            int count = 0;
236            for (String key : keys) {
237                String value = config.get(key, null);
238                if (value != null && !value.trim().isEmpty()) {
239                    count++;
240                }
241            }
242            if (count == 0) {
243                throw new ConfigurationException(
244                    "Exactly one of these properties must be present: " + String.join(", ", keys)
245                );
246            } else if (count > 1) {
247                throw new ConfigurationException(
248                    "Only one of these properties can be present: " + String.join(", ", keys)
249                );
250            }
251        }));
252        return this;
253    }
254
255    /**
256     * Validate that a boolean property has a valid value.
257     * Accepted values: true, false, yes, no, 1, 0 (case-insensitive).
258     * Only validates if the property is present.
259     * @param key the property key
260     * @return this validator for chaining
261     */
262    public ConfigValidator validBoolean(String key) {
263        rules.add(new ValidationRule(key, config -> {
264            String value = config.get(key, null);
265            if (value != null && !value.trim().isEmpty()) {
266                String v = value.trim().toLowerCase();
267                if (!v.equals("true") && !v.equals("false") &&
268                    !v.equals("yes") && !v.equals("no") &&
269                    !v.equals("1") && !v.equals("0")) {
270                    throw new ConfigurationException(
271                        "Property '" + key + "' must be a boolean (true/false/yes/no/1/0): " + value
272                    );
273                }
274            }
275        }));
276        return this;
277    }
278
279    /**
280     * Validate the configuration.
281     * @param config the configuration to validate
282     * @throws ConfigurationException if validation fails
283     */
284    public void validate(Configuration config) throws ConfigurationException {
285        for (ValidationRule rule : rules) {
286            rule.validate(config);
287        }
288    }
289
290    /**
291     * Internal validation rule.
292     */
293    private static class ValidationRule {
294        private final String key;
295        private final ValidationFunction validator;
296
297        ValidationRule(String key, ValidationFunction validator) {
298            this.key = key;
299            this.validator = validator;
300        }
301
302        void validate(Configuration config) throws ConfigurationException {
303            validator.validate(config);
304        }
305    }
306
307    /**
308     * Validation function interface.
309     */
310    @FunctionalInterface
311    private interface ValidationFunction {
312        void validate(Configuration config) throws ConfigurationException;
313    }
314}