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    protected Channel[] initialChannels;
055
056    /**
057     * Creates a new component with its channel set to the given 
058     * channel and the given file. The component handles
059     * {@link ConfigurationUpdate} events and {@link FileChanged}
060     * events for the configuration file (see
061     * @link #NightConfigStore(Channel, File, boolean, boolean)}
062     * 
063     * @param componentChannel the channel 
064     * @param file the file used to store the configuration
065     * @throws IOException
066     */
067    @Deprecated
068    public NightConfigStore(Channel componentChannel, File file)
069            throws IOException {
070        this(componentChannel, file, true, true);
071    }
072
073    /**
074     * Creates a new component with its channel set to the given 
075     * channel and the given file. The component handles
076     * {@link FileChanged} events for the configuration file (see
077     * @link #NightConfigStore(Channel, File, boolean, boolean)}
078     * 
079     * If `update` is `true`, the configuration file is updated
080     * when {@link ConfigurationUpdate} events are received.  
081     *
082     * @param componentChannel the channel
083     * @param file the file used to store the configuration
084     * @param update if the configuration file is to be updated
085     * @throws IOException Signals that an I/O exception has occurred.
086     */
087    @Deprecated
088    @SuppressWarnings("PMD.ShortVariable")
089    public NightConfigStore(Channel componentChannel, File file,
090            boolean update) throws IOException {
091        this(componentChannel, file, update, true);
092    }
093
094    /**
095     * Creates a new component with its channel set to the given 
096     * channel and the given file.
097     * 
098     * If `update` is `true`, the configuration file is updated
099     * when {@link ConfigurationUpdate} events are received.  
100     * 
101     * If `watch` is `true`, {@link FileChanged} events are processed
102     * and the configuration file is reloaded when it changes. Note
103     * that the generation of the {@link FileChanged} events must
104     * be configured independently (see {@link FileSystemWatcher}).
105     *
106     * @param componentChannel the channel
107     * @param file the file used to store the configuration
108     * @param update if the configuration file is to be updated
109     * @param watch if {@link FileChanged} events are to be processed
110     * @throws IOException Signals that an I/O exception has occurred.
111     */
112    @SuppressWarnings("PMD.ShortVariable")
113    public NightConfigStore(Channel componentChannel, File file,
114            boolean update, boolean watch) throws IOException {
115        super(componentChannel);
116        if (update) {
117            Handler.Evaluator.add(this, "onConfigurationUpdate",
118                channel().defaultCriterion());
119        }
120        if (watch) {
121            Handler.Evaluator.add(this, "onFileChanged",
122                channel().defaultCriterion());
123        }
124        if (!file.exists()) {
125            file.createNewFile();
126        }
127    }
128
129    /**
130     * If watching the configuration file is enabled, fire
131     * a {@link ConfigurationUpdate} event with the complete
132     * configuration when the file changes. The event is fired
133     * on the same channel(s) as the {@link InitialConfiguration}
134     * event.
135     *
136     * @param event the event
137     */
138    @Handler(dynamic = true)
139    public void onFileChanged(FileChanged event) {
140        if (config.getNioPath().equals(event.path())
141            && event.change() == FileChanged.Kind.MODIFIED
142            && initialChannels != null) {
143            config.load();
144            ConfigurationUpdate updEvt = new ConfigurationUpdate();
145            addPrefs(updEvt, "/", config);
146            fire(updEvt, initialChannels);
147        }
148    }
149
150    @Override
151    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
152        "PMD.AvoidBranchingStatementAsLastInLoop", "PMD.CloseResource" })
153    public Optional<Map<String, Object>> structured(String path) {
154        if (!path.startsWith("/")) {
155            throw new IllegalArgumentException("Path must start with \"/\".");
156        }
157
158        // Walk down to node.
159        var segs = new StringTokenizer(path, "/");
160        @SuppressWarnings("PMD.CloseResource")
161        Config cur = config;
162        while (segs.hasMoreTokens()) {
163            var nextSeg = segs.nextToken();
164            Object next = Optional.ofNullable(cur.get("_" + nextSeg))
165                .orElse(cur.get("/" + nextSeg));
166            if (next instanceof Config) {
167                cur = (Config) next;
168                continue;
169            }
170            return Optional.empty();
171        }
172        return Optional.of(toValueMap(cur));
173    }
174
175    private Map<String, Object> toValueMap(Config config) {
176        @SuppressWarnings("PMD.UseConcurrentHashMap")
177        Map<String, Object> result = new HashMap<>();
178        for (var entry : config.entrySet()) {
179            if (isNode(entry.getKey())) {
180                continue;
181            }
182            if (entry.getValue() instanceof Config) {
183                result.put(entry.getKey(), toValueMap(entry.getValue()));
184                continue;
185            }
186            if (entry.getValue() instanceof List<?> values) {
187                result.put(entry.getKey(), convertList(values));
188                continue;
189            }
190            result.put(entry.getKey(), entry.getValue());
191        }
192        return result;
193    }
194
195    private List<Object> convertList(List<?> values) {
196        List<Object> copy = new ArrayList<>();
197        for (var element : values) {
198            if (element instanceof Config cfg) {
199                copy.add(toValueMap(cfg));
200                continue;
201            }
202            copy.add(element);
203        }
204        return copy;
205    }
206
207    /**
208     * Checks if the name is an entry for a node.
209     *
210     * @param name the name
211     * @return true, if is node
212     */
213    protected boolean isNode(String name) {
214        if (name == null || name.length() < 1) {
215            return false;
216        }
217        char first = name.charAt(0);
218        return first == '_' || first == '/';
219    }
220
221    /**
222     * Intercepts the {@link Start} event and fires a
223     * {@link ConfigurationUpdate} event.
224     *
225     * @param event the event
226     * @throws BackingStoreException the backing store exception
227     * @throws InterruptedException the interrupted exception
228     */
229    @Handler(priority = 999_999, channels = Channel.class)
230    @SuppressWarnings("PMD.CognitiveComplexity")
231    public void onStart(Start event)
232            throws BackingStoreException, InterruptedException {
233        ConfigurationUpdate updEvt = new InitialConfiguration();
234        addPrefs(updEvt, "/", config);
235        initialChannels = event.channels();
236        newEventPipeline().fire(updEvt, initialChannels).get();
237    }
238
239    private void addPrefs(ConfigurationUpdate updEvt, String path,
240            Config config) {
241        @SuppressWarnings("PMD.UseConcurrentHashMap")
242        Map<String, Object> atPath = new HashMap<>();
243        for (var e : config.entrySet()) {
244            if (isNode(e.getKey()) && e.getValue() instanceof Config) {
245                addPrefs(updEvt, ("/".equals(path) ? "" : path)
246                    + "/" + e.getKey().substring(1), e.getValue());
247                continue;
248            }
249            if (e.getValue() instanceof Config) {
250                atPath.put(e.getKey(), toValueMap(e.getValue()));
251            } else if (e.getValue() instanceof List<?> values) {
252                atPath.put(e.getKey(), convertList(values));
253            } else {
254                atPath.put(e.getKey(), e.getValue());
255            }
256        }
257        if (!atPath.isEmpty()) {
258            updEvt.set(path, atPath);
259        }
260    }
261
262    /**
263     * Merges and saves configuration updates.
264     *
265     * @param event the event
266     * @throws IOException Signals that an I/O exception has occurred.
267     */
268    @Handler(dynamic = true)
269    @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity",
270        "PMD.AvoidLiteralsInIfCondition",
271        "PMD.AvoidInstantiatingObjectsInLoops" })
272    public void onConfigurationUpdate(ConfigurationUpdate event)
273            throws IOException {
274        if (event instanceof InitialConfiguration) {
275            return;
276        }
277
278        boolean changed = false;
279        for (String path : event.paths()) {
280            if ("/".equals(path) && event.values(path).isEmpty()) {
281                // Special case, "remove root", i.e. all configuration data
282                config.clear();
283                changed = true;
284                continue;
285            }
286            if (handleSegment(config, new StringTokenizer(path, "/"),
287                event.structured(path).map(ConfigurationStore::flatten))) {
288                changed = true;
289            }
290        }
291        if (changed) {
292            config.save();
293        }
294    }
295
296    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
297    private boolean handleSegment(Config config,
298            StringTokenizer tokenizer, Optional<Map<String, Object>> values) {
299        if (!tokenizer.hasMoreTokens()) {
300            // "Leaf" map
301            return mergeValues(config, values.get());
302        }
303        boolean changed = false;
304        var nextSeg = tokenizer.nextToken();
305        var usSel = List.of("_" + nextSeg);
306        var slashSel = List.of("/" + nextSeg);
307        if (!tokenizer.hasMoreTokens() && values.isEmpty()) {
308            // Selected is last segment from path and we must remove
309            for (var sel : List.of(usSel, slashSel)) {
310                if (config.get(sel) != null) {
311                    // Delete sub-map.
312                    config.remove(sel);
313                    changed = true;
314                }
315            }
316            return changed;
317        }
318        // Check if sub config exists
319        Object subConfig = Optional.ofNullable(config.get(usSel))
320            .orElse(config.get(slashSel));
321        if (!(subConfig instanceof Config)) {
322            // Doesn't exist or is of wrong type, new sub-map
323            changed = true;
324            subConfig = config.createSubConfig();
325            config.set(usSel, subConfig);
326        }
327        // Continue with sub-map
328        return handleSegment((Config) subConfig, tokenizer, values) || changed;
329    }
330
331    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
332        "PMD.CognitiveComplexity" })
333    private boolean mergeValues(Config config, Map<String, Object> values) {
334        boolean changed = false;
335        Map<String, Object> curValues = flatten(toValueMap(config));
336        for (var e : values.entrySet()) {
337            if (e.getValue() == null) {
338                // Delete from map (and config)
339                if (curValues.containsKey(e.getKey())) {
340                    curValues.remove(e.getKey());
341                    changed = true;
342                }
343                continue;
344            }
345            Object oldValue = curValues.get(e.getKey());
346            if (oldValue == null || !e.getValue().equals(oldValue)) {
347                curValues.put(e.getKey(), e.getValue());
348                changed = true;
349            }
350        }
351        if (changed) {
352            for (var itr = config.entrySet().iterator(); itr.hasNext();) {
353                if (!isNode(itr.next().getKey())) {
354                    itr.remove();
355                }
356            }
357            addToConfig(config, structure(curValues));
358        }
359        return changed;
360    }
361
362    @SuppressWarnings("unchecked")
363    private void addToConfig(Config config, Map<String, Object> map) {
364        for (var e : map.entrySet()) {
365            var selector = List.of(e.getKey());
366            if (e.getValue() instanceof Map) {
367                Config subConfig = config.get(selector);
368                if (subConfig == null) {
369                    subConfig = config.createSubConfig();
370                    config.set(selector, subConfig);
371                }
372                addToConfig(subConfig, (Map<String, Object>) e.getValue());
373            } else if (e.getValue() instanceof Collection) {
374                config.set(selector,
375                    checkCollection((Collection<?>) e.getValue()));
376            } else {
377                config.set(selector, e.getValue());
378            }
379        }
380    }
381
382    @SuppressWarnings("unchecked")
383    private Collection<Object> checkCollection(Collection<?> items) {
384        var checked = new ArrayList<>();
385        for (var item : items) {
386            if (item instanceof Map) {
387                Config subConfig = config.createSubConfig();
388                addToConfig(subConfig, (Map<String, Object>) item);
389                checked.add(subConfig);
390            } else if (item instanceof Collection) {
391                checked.add(checkCollection((Collection<?>) item));
392            } else {
393                checked.add(item);
394            }
395        }
396        return checked;
397    }
398
399}