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    private final List<ValidationRule> rules = new ArrayList<>();
052
053    /**
054     * Validate that a property is present and non-empty.
055     * @param key the property key
056     * @return this validator for chaining
057     */
058    public ConfigValidator required(String key) {
059        rules.add(new ValidationRule(key, config -> {
060            String value = config.get(key, null);
061            if (value == null || value.trim().isEmpty()) {
062                throw new ConfigurationException("Required property '" + key + "' is missing or empty");
063            }
064        }));
065        return this;
066    }
067
068    /**
069     * Validate that a property, if present, is non-empty.
070     * @param key the property key
071     * @return this validator for chaining
072     */
073    public ConfigValidator nonEmpty(String key) {
074        rules.add(new ValidationRule(key, config -> {
075            String value = config.get(key, null);
076            if (value != null && value.trim().isEmpty()) {
077                throw new ConfigurationException("Property '" + key + "' must not be empty");
078            }
079        }));
080        return this;
081    }
082
083    /**
084     * Validate that an integer property is within a specified range.
085     * Only validates if the property is present.
086     * @param key the property key
087     * @param min minimum value (inclusive)
088     * @param max maximum value (inclusive)
089     * @return this validator for chaining
090     */
091    public ConfigValidator range(String key, int min, int max) {
092        rules.add(new ValidationRule(key, config -> {
093            String strValue = config.get(key, null);
094            if (strValue != null && !strValue.trim().isEmpty()) {
095                try {
096                    int value = Integer.parseInt(strValue.trim());
097                    if (value < min || value > max) {
098                        throw new ConfigurationException(
099                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
100                        );
101                    }
102                } catch (NumberFormatException e) {
103                    throw new ConfigurationException("Property '" + key + "' must be an integer: " + strValue);
104                }
105            }
106        }));
107        return this;
108    }
109
110    /**
111     * Validate that a long property is within a specified range.
112     * Only validates if the property is present.
113     * @param key the property key
114     * @param min minimum value (inclusive)
115     * @param max maximum value (inclusive)
116     * @return this validator for chaining
117     */
118    public ConfigValidator rangeLong(String key, long min, long max) {
119        rules.add(new ValidationRule(key, config -> {
120            String strValue = config.get(key, null);
121            if (strValue != null && !strValue.trim().isEmpty()) {
122                try {
123                    long value = Long.parseLong(strValue.trim());
124                    if (value < min || value > max) {
125                        throw new ConfigurationException(
126                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
127                        );
128                    }
129                } catch (NumberFormatException e) {
130                    throw new ConfigurationException("Property '" + key + "' must be a long: " + strValue);
131                }
132            }
133        }));
134        return this;
135    }
136
137    /**
138     * Validate that a double property is within a specified range.
139     * Only validates if the property is present.
140     * @param key the property key
141     * @param min minimum value (inclusive)
142     * @param max maximum value (inclusive)
143     * @return this validator for chaining
144     */
145    public ConfigValidator rangeDouble(String key, double min, double max) {
146        rules.add(new ValidationRule(key, config -> {
147            String strValue = config.get(key, null);
148            if (strValue != null && !strValue.trim().isEmpty()) {
149                try {
150                    double value = Double.parseDouble(strValue.trim());
151                    if (value < min || value > max) {
152                        throw new ConfigurationException(
153                            "Property '" + key + "' must be between " + min + " and " + max + ": " + value
154                        );
155                    }
156                } catch (NumberFormatException e) {
157                    throw new ConfigurationException("Property '" + key + "' must be a double: " + strValue);
158                }
159            }
160        }));
161        return this;
162    }
163
164    /**
165     * Validate that a property matches a regex pattern.
166     * Only validates if the property is present.
167     * @param key the property key
168     * @param regex the regular expression pattern
169     * @return this validator for chaining
170     */
171    public ConfigValidator pattern(String key, String regex) {
172        Pattern compiled = Pattern.compile(regex);
173        rules.add(new ValidationRule(key, config -> {
174            String value = config.get(key, null);
175            if (value != null && !value.trim().isEmpty()) {
176                if (!compiled.matcher(value.trim()).matches()) {
177                    throw new ConfigurationException(
178                        "Property '" + key + "' does not match pattern '" + regex + "': " + value
179                    );
180                }
181            }
182        }));
183        return this;
184    }
185
186    /**
187     * Validate a property using a custom predicate.
188     * Only validates if the property is present.
189     * @param key the property key
190     * @param predicate the validation predicate (returns true if valid)
191     * @param errorMessage error message if validation fails
192     * @return this validator for chaining
193     */
194    public ConfigValidator custom(String key, Predicate<String> predicate, String errorMessage) {
195        rules.add(new ValidationRule(key, config -> {
196            String value = config.get(key, null);
197            if (value != null && !value.trim().isEmpty()) {
198                if (!predicate.test(value.trim())) {
199                    throw new ConfigurationException("Property '" + key + "' " + errorMessage + ": " + value);
200                }
201            }
202        }));
203        return this;
204    }
205
206    /**
207     * Validate that at least one of the specified properties is present.
208     * @param keys the property keys
209     * @return this validator for chaining
210     */
211    public ConfigValidator requireAtLeastOne(String... keys) {
212        rules.add(new ValidationRule("requireAtLeastOne", config -> {
213            for (String key : keys) {
214                String value = config.get(key, null);
215                if (value != null && !value.trim().isEmpty()) {
216                    return;
217                }
218            }
219            throw new ConfigurationException(
220                "At least one of these properties must be present: " + String.join(", ", keys)
221            );
222        }));
223        return this;
224    }
225
226    /**
227     * Validate that exactly one of the specified properties is present.
228     * @param keys the property keys
229     * @return this validator for chaining
230     */
231    public ConfigValidator requireExactlyOne(String... keys) {
232        rules.add(new ValidationRule("requireExactlyOne", config -> {
233            int count = 0;
234            for (String key : keys) {
235                String value = config.get(key, null);
236                if (value != null && !value.trim().isEmpty()) {
237                    count++;
238                }
239            }
240            if (count == 0) {
241                throw new ConfigurationException(
242                    "Exactly one of these properties must be present: " + String.join(", ", keys)
243                );
244            } else if (count > 1) {
245                throw new ConfigurationException(
246                    "Only one of these properties can be present: " + String.join(", ", keys)
247                );
248            }
249        }));
250        return this;
251    }
252
253    /**
254     * Validate that a boolean property has a valid value.
255     * Accepted values: true, false, yes, no, 1, 0 (case-insensitive).
256     * Only validates if the property is present.
257     * @param key the property key
258     * @return this validator for chaining
259     */
260    public ConfigValidator validBoolean(String key) {
261        rules.add(new ValidationRule(key, config -> {
262            String value = config.get(key, null);
263            if (value != null && !value.trim().isEmpty()) {
264                String v = value.trim().toLowerCase();
265                if (!v.equals("true") && !v.equals("false") &&
266                    !v.equals("yes") && !v.equals("no") &&
267                    !v.equals("1") && !v.equals("0")) {
268                    throw new ConfigurationException(
269                        "Property '" + key + "' must be a boolean (true/false/yes/no/1/0): " + value
270                    );
271                }
272            }
273        }));
274        return this;
275    }
276
277    /**
278     * Validate the configuration.
279     * @param config the configuration to validate
280     * @throws ConfigurationException if validation fails
281     */
282    public void validate(Configuration config) throws ConfigurationException {
283        for (ValidationRule rule : rules) {
284            rule.validate(config);
285        }
286    }
287
288    /**
289     * Internal validation rule.
290     */
291    private static class ValidationRule {
292        private final String key;
293        private final ValidationFunction validator;
294
295        ValidationRule(String key, ValidationFunction validator) {
296            this.key = key;
297            this.validator = validator;
298        }
299
300        void validate(Configuration config) throws ConfigurationException {
301            validator.validate(config);
302        }
303    }
304
305    /**
306     * Validation function interface.
307     */
308    @FunctionalInterface
309    private interface ValidationFunction {
310        void validate(Configuration config) throws ConfigurationException;
311    }
312}