001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.util;
020
021import com.electronwill.nightconfig.core.Config;
022import com.electronwill.nightconfig.core.file.FileConfig;
023import java.io.File;
024import java.io.IOException;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Optional;
031import java.util.StringTokenizer;
032import java.util.logging.Logger;
033import java.util.prefs.BackingStoreException;
034import org.jgrapes.core.Channel;
035import org.jgrapes.core.annotation.Handler;
036import org.jgrapes.core.events.Start;
037import org.jgrapes.util.events.ConfigurationUpdate;
038import org.jgrapes.util.events.FileChanged;
039import org.jgrapes.util.events.InitialConfiguration;
040
041/**
042 * A base class for configuration stored based on the 
043 * [night config library](https://github.com/TheElectronWill/night-config).
044 */
045@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals",
046    "PMD.GodClass" })
047public abstract class NightConfigStore extends ConfigurationStore {
048
049    @SuppressWarnings("PMD.FieldNamingConventions")
050    protected static final Logger logger
051        = Logger.getLogger(NightConfigStore.class.getName());
052
053    protected FileConfig config;
054
055    /**
056     * Creates a new component with its channel set to the given 
057     * channel and the given file. The component handles
058     * {@link ConfigurationUpdate} events and {@link FileChanged}
059     * events for the configuration file (see
060     * @link #NightConfigStore(Channel, File, boolean, boolean)}
061     * 
062     * @param componentChannel the channel 
063     * @param file the file used to store the configuration
064     * @throws IOException
065     */
066    @Deprecated
067    public NightConfigStore(Channel componentChannel, File file)
068            throws IOException {
069        this(componentChannel, file, true, true);
070    }
071
072    /**
073     * Creates a new component with its channel set to the given 
074     * channel and the given file. The component handles
075     * {@link FileChanged} events for the configuration file (see
076     * @link #NightConfigStore(Channel, File, boolean, boolean)}
077     * 
078     * If `update` is `true`, the configuration file is updated
079     * when {@link ConfigurationUpdate} events are received.  
080     *
081     * @param componentChannel the channel
082     * @param file the file used to store the configuration
083     * @param update if the configuration file is to be updated
084     * @throws IOException Signals that an I/O exception has occurred.
085     */
086    @Deprecated
087    @SuppressWarnings("PMD.ShortVariable")
088    public NightConfigStore(Channel componentChannel, File file,
089            boolean update) throws IOException {
090        this(componentChannel, file, update, true);
091    }
092
093    /**
094     * Creates a new component with its channel set to the given 
095     * channel and the given file.
096     * 
097     * If `update` is `true`, the configuration file is updated
098     * when {@link ConfigurationUpdate} events are received.  
099     * 
100     * If `watch` is `true`, {@link FileChanged} events are processed
101     * and the configuration file is reloaded when it changes. Note
102     * that the generation of the {@link FileChanged} events must
103     * be configured independently (see {@link FileSystemWatcher}).
104     *
105     * @param componentChannel the channel
106     * @param file the file used to store the configuration
107     * @param update if the configuration file is to be updated
108     * @param watch if {@link FileChanged} events are to be processed
109     * @throws IOException Signals that an I/O exception has occurred.
110     */
111    @SuppressWarnings("PMD.ShortVariable")
112    public NightConfigStore(Channel componentChannel, File file,
113            boolean update, boolean watch) throws IOException {
114        super(componentChannel);
115        if (update) {
116            Handler.Evaluator.add(this, "onConfigurationUpdate",
117                channel().defaultCriterion());
118        }
119        if (watch) {
120            Handler.Evaluator.add(this, "onFileChanged",
121                channel().defaultCriterion());
122        }
123        if (!file.exists()) {
124            file.createNewFile();
125        }
126    }
127
128    /**
129     * If watching the configuration file is enabled, fire
130     * a {@link ConfigurationUpdate} event with the complete
131     * configuration when the file changes.
132     *
133     * @param event the event
134     */
135    @Handler(dynamic = true)
136    public void onFileChanged(FileChanged event) {
137        if (config.getNioPath().equals(event.path())
138            && event.change() == FileChanged.Kind.MODIFIED) {
139            config.load();
140            ConfigurationUpdate updEvt = new ConfigurationUpdate();
141            addPrefs(updEvt, "/", config);
142            fire(updEvt);
143        }
144    }
145
146    @Override
147    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
148        "PMD.AvoidBranchingStatementAsLastInLoop" })
149    public Optional<Map<String, Object>> structured(String path) {
150        if (!path.startsWith("/")) {
151            throw new IllegalArgumentException("Path must start with \"/\".");
152        }
153
154        // Walk down to node.
155        var segs = new StringTokenizer(path, "/");
156        @SuppressWarnings("PMD.CloseResource")
157        Config cur = config;
158        while (segs.hasMoreTokens()) {
159            var nextSeg = segs.nextToken();
160            Object next = Optional.ofNullable(cur.get("_" + nextSeg))
161                .orElse(cur.get("/" + nextSeg));
162            if (next instanceof Config) {
163                cur = (Config) next;
164                continue;
165            }
166            return Optional.empty();
167        }
168        return Optional.of(toValueMap(cur));
169    }
170
171    private Map<String, Object> toValueMap(Config config) {
172        @SuppressWarnings("PMD.UseConcurrentHashMap")
173        Map<String, Object> result = new HashMap<>();
174        for (var entry : config.entrySet()) {
175            if (isNode(entry.getKey())) {
176                continue;
177            }
178            if (entry.getValue() instanceof Config) {
179                result.put(entry.getKey(), toValueMap(entry.getValue()));
180                continue;
181            }
182            if (entry.getValue() instanceof List<?> values) {
183                result.put(entry.getKey(), convertList(values));
184                continue;
185            }
186            result.put(entry.getKey(), entry.getValue());
187        }
188        return result;
189    }
190
191    private List<Object> convertList(List<?> values) {
192        List<Object> copy = new ArrayList<>();
193        for (var element : values) {
194            if (element instanceof Config cfg) {
195                copy.add(toValueMap(cfg));
196                continue;
197            }
198            copy.add(element);
199        }
200        return copy;
201    }
202
203    /**
204     * Checks if the name is an entry for a node.
205     *
206     * @param name the name
207     * @return true, if is node
208     */
209    protected boolean isNode(String name) {
210        if (name == null || name.length() < 1) {
211            return false;
212        }
213        char first = name.charAt(0);
214        return first == '_' || first == '/';
215    }
216
217    /**
218     * Intercepts the {@link Start} event and fires a
219     * {@link ConfigurationUpdate} event.
220     *
221     * @param event the event
222     * @throws BackingStoreException the backing store exception
223     * @throws InterruptedException the interrupted exception
224     */
225    @Handler(priority = 999_999, channels = Channel.class)
226    @SuppressWarnings("PMD.CognitiveComplexity")
227    public void onStart(Start event)
228            throws BackingStoreException, InterruptedException {
229        ConfigurationUpdate updEvt = new InitialConfiguration();
230        addPrefs(updEvt, "/", config);
231        newEventPipeline().fire(updEvt, event.channels()).get();
232    }
233
234    private void addPrefs(ConfigurationUpdate updEvt, String path,
235            Config config) {
236        @SuppressWarnings("PMD.UseConcurrentHashMap")
237        Map<String, Object> atPath = new HashMap<>();
238        for (var e : config.entrySet()) {
239            if (isNode(e.getKey()) && e.getValue() instanceof Config) {
240                addPrefs(updEvt, ("/".equals(path) ? "" : path)
241                    + "/" + e.getKey().substring(1), e.getValue());
242                continue;
243            }
244            if (e.getValue() instanceof Config) {
245                atPath.put(e.getKey(), toValueMap(e.getValue()));
246            } else {
247                atPath.put(e.getKey(), e.getValue());
248            }
249        }
250        if (!atPath.isEmpty()) {
251            updEvt.set(path, atPath);
252        }
253    }
254
255    /**
256     * Merges and saves configuration updates.
257     *
258     * @param event the event
259     * @throws IOException Signals that an I/O exception has occurred.
260     */
261    @Handler(dynamic = true)
262    @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity",
263        "PMD.AvoidLiteralsInIfCondition",
264        "PMD.AvoidInstantiatingObjectsInLoops" })
265    public void onConfigurationUpdate(ConfigurationUpdate event)
266            throws IOException {
267        if (event instanceof InitialConfiguration) {
268            return;
269        }
270
271        boolean changed = false;
272        for (String path : event.paths()) {
273            if ("/".equals(path) && event.values(path).isEmpty()) {
274                // Special case, "remove root", i.e. all configuration data
275                config.clear();
276                changed = true;
277                continue;
278            }
279            if (handleSegment(config, new StringTokenizer(path, "/"),
280                event.structured(path).map(ConfigurationStore::flatten))) {
281                changed = true;
282            }
283        }
284        if (changed) {
285            config.save();
286        }
287    }
288
289    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
290    private boolean handleSegment(Config config,
291            StringTokenizer tokenizer, Optional<Map<String, Object>> values) {
292        if (!tokenizer.hasMoreTokens()) {
293            // "Leaf" map
294            return mergeValues(config, values.get());
295        }
296        boolean changed = false;
297        var nextSeg = tokenizer.nextToken();
298        var usSel = List.of("_" + nextSeg);
299        var slashSel = List.of("/" + nextSeg);
300        if (!tokenizer.hasMoreTokens() && values.isEmpty()) {
301            // Selected is last segment from path and we must remove
302            for (var sel : List.of(usSel, slashSel)) {
303                if (config.get(sel) != null) {
304                    // Delete sub-map.
305                    config.remove(sel);
306                    changed = true;
307                }
308            }
309            return changed;
310        }
311        // Check if sub config exists
312        Object subConfig = Optional.ofNullable(config.get(usSel))
313            .orElse(config.get(slashSel));
314        if (!(subConfig instanceof Config)) {
315            // Doesn't exist or is of wrong type, new sub-map
316            changed = true;
317            subConfig = config.createSubConfig();
318            config.set(usSel, subConfig);
319        }
320        // Continue with sub-map
321        return handleSegment((Config) subConfig, tokenizer, values) || changed;
322    }
323
324    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
325        "PMD.CognitiveComplexity" })
326    private boolean mergeValues(Config config, Map<String, Object> values) {
327        boolean changed = false;
328        Map<String, Object> curValues = flatten(toValueMap(config));
329        for (var e : values.entrySet()) {
330            if (e.getValue() == null) {
331                // Delete from map (and config)
332                if (curValues.containsKey(e.getKey())) {
333                    curValues.remove(e.getKey());
334                    changed = true;
335                }
336                continue;
337            }
338            Object oldValue = curValues.get(e.getKey());
339            if (oldValue == null || !e.getValue().equals(oldValue)) {
340                curValues.put(e.getKey(), e.getValue());
341                changed = true;
342            }
343        }
344        if (changed) {
345            for (var itr = config.entrySet().iterator(); itr.hasNext();) {
346                if (!isNode(itr.next().getKey())) {
347                    itr.remove();
348                }
349            }
350            addToConfig(config, structure(curValues));
351        }
352        return changed;
353    }
354
355    @SuppressWarnings("unchecked")
356    private void addToConfig(Config config, Map<String, Object> map) {
357        for (var e : map.entrySet()) {
358            var selector = List.of(e.getKey());
359            if (e.getValue() instanceof Map) {
360                Config subConfig = config.get(selector);
361                if (subConfig == null) {
362                    subConfig = config.createSubConfig();
363                    config.set(selector, subConfig);
364                }
365                addToConfig(subConfig, (Map<String, Object>) e.getValue());
366            } else if (e.getValue() instanceof Collection) {
367                config.set(selector,
368                    checkCollection((Collection<?>) e.getValue()));
369            } else {
370                config.set(selector, e.getValue());
371            }
372        }
373    }
374
375    @SuppressWarnings("unchecked")
376    private Collection<Object> checkCollection(Collection<?> items) {
377        var checked = new ArrayList<>();
378        for (var item : items) {
379            if (item instanceof Map) {
380                Config subConfig = config.createSubConfig();
381                addToConfig(subConfig, (Map<String, Object>) item);
382                checked.add(subConfig);
383            } else if (item instanceof Collection) {
384                checked.add(checkCollection((Collection<?>) item));
385            } else {
386                checked.add(item);
387            }
388        }
389        return checked;
390    }
391
392}