001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2024-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.common.context;
019
020import static java.util.Objects.requireNonNull;
021
022import org.jspecify.annotations.Nullable;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025
026import java.io.IOException;
027import java.util.Optional;
028
029/**
030 * Contexts đź§ż put things into perspective!
031 *
032 * <p>Contexts are "hierarchical", and child contexts "mask" keys in their parent.
033 *
034 * <p>This class is NOT thread safe. Might you want to use {@link TLC} instead?
035 *
036 * @author <a href="http://www.vorburger.ch">Michael Vorburger.ch</a>
037 */
038public class Context implements AutoCloseable {
039
040    // TODO Change this to not permit pushing to a key that's already set
041
042    // TODO Change this to have a separate Context.Builder
043
044    public interface Key<T> {}
045
046    private static final Logger LOG = LoggerFactory.getLogger(Context.class);
047
048    private final @Nullable Context parent;
049
050    @Nullable Entry last = null;
051    private boolean closed = false;
052
053    public Context(Context parent) {
054        this.parent = requireNonNull(parent);
055    }
056
057    public Context() {
058        this.parent = null;
059    }
060
061    /**
062     * Push, but not too hard…
063     *
064     * @param key Key.
065     * @param value Value to associate with the key.
066     * @return this, for chaining.
067     * @throws IllegalStateException if this Context already has another value for the key.
068     */
069    public <K extends Enum<K> & Key<T>, T> Context push(K key, T value) {
070        return _push(key, value);
071    }
072
073    public <T> void push(Key<T> key, T instance) {
074        _push(key, instance);
075    }
076
077    public <T> Context push(Class<T> key, T value) {
078        _push(key, value); // NOT .getName()
079        return this;
080    }
081
082    private Context _push(Object key, Object value) {
083        check();
084        if (_getJustThisLevel(key) != null)
085            throw new IllegalStateException(
086                    "Use nesting; this Context already has another value for: #" + key);
087        last = new Entry(key, value, last);
088        return this;
089    }
090
091    private Object _get(Object key) {
092        if (isEmpty()) throw new IllegalStateException("Context is empty, no: " + key);
093        var object = _getRecursive(key);
094        if (object == null)
095            throw new IllegalStateException("Context has no " + key + "; only:\n" + toString("  "));
096        return object;
097    }
098
099    /**
100     * Get the value for the given key, from this or its parent context.
101     *
102     * <p>Never null, but may throw IllegalStateException if not available.
103     *
104     * <p>Use {@link #optional(Enum)} to check if key is available in Context.
105     */
106    public <K extends Enum<K> & Key<T>, T> T get(K key) {
107        // TODO Better error message instead of ClassCastException ? See below...
108        return (T) _get(key);
109    }
110
111    /**
112     * Gets the instance of Class, from this or its parent context.
113     *
114     * <p>Never null, but may throw IllegalStateException if not available.
115     *
116     * <p>Use {@link #optional(Class)} to check if key is available in Context.
117     */
118    @SuppressWarnings("unchecked")
119    public <T> T get(Class<T> key) {
120        var object = _get(key);
121        if (key.isInstance(object)) return (T) object;
122        throw new IllegalStateException("Context's " + key + " is a " + object.getClass());
123    }
124
125    /**
126     * Gets the instance of Class, from this or its parent context, if available.
127     *
128     * <p>Use {@link #get(Class)} if key must be available in Context.
129     */
130    public <T> Optional<T> optional(Class<T> key) {
131        return Optional.ofNullable((T) _getRecursive(key));
132    }
133
134    /**
135     * Get the value for the given key, from this or its parent context, if available.
136     *
137     * <p>Use {@link #get(Enum)} if key must be available in Context.
138     */
139    public <T, K extends Enum<K> & Key<T>> Optional<T> optional(K key) {
140        return Optional.ofNullable((T) _getRecursive(key));
141    }
142
143    private @Nullable Object _getJustThisLevel(Object key) {
144        var current = last;
145        while (current != null) {
146            if (current.key.equals(key)) {
147                return current.value;
148            }
149            current = current.previous;
150        }
151        return null;
152    }
153
154    private @Nullable Object _getRecursive(Object key) {
155        check();
156        var current = last;
157        while (current != null) {
158            if (current.key.equals(key)) {
159                return current.value;
160            }
161            current = current.previous;
162        }
163        if (parent != null) return parent._getRecursive(key);
164        else return null;
165    }
166
167    private boolean isEmpty() {
168        return last == null && parent == null;
169    }
170
171    // Nota bene: This (kind of) Stack-like data structure (intentionally)
172    // does not have (need) any pop() ("goes the weasel”) kind of method!
173
174    void append(Appendable a, String indent) {
175        try {
176            var current = last;
177            while (current != null) {
178                a.append(indent);
179                a.append(current.key.toString());
180                a.append(" => ");
181                a.append(current.value.toString());
182                a.append('\n');
183                current = current.previous;
184            }
185            if (parent != null) parent.append(a, indent + ContextualizedException.INDENT);
186        } catch (IOException e) {
187            LOG.error("append() hit an IOException", e);
188        }
189    }
190
191    private String toString(String indent) {
192        var sb = new StringBuilder();
193        append(sb, indent);
194        return sb.toString();
195    }
196
197    public String toString() {
198        return toString("");
199    }
200
201    /** Close this context. Don't use it anymore! */
202    @Override
203    public void close() {
204        closed = true;
205        TLC.reset(parent);
206
207        // NB: It's tempting to do "last = null" here, intending to free up memory;
208        // but doing so breaks e.g. the ContextsTest#exceptionWithContext(). We do
209        // NOT have to do it to free memory, because Context (should) will be GC.
210    }
211
212    private void check() {
213        if (closed) {
214            throw new IllegalStateException("Context already closed");
215        }
216    }
217
218    private record Entry(Object key, Object value, @Nullable Entry previous) {
219        private Entry(Object key, Object value, @Nullable Entry previous) {
220            this.key = key;
221            this.value = value;
222            this.previous = previous;
223        }
224    }
225}