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.model.enola.meta.io;
019
020import dev.enola.common.context.TLC;
021import dev.enola.common.io.resource.ReadableResource;
022import dev.enola.common.yamljson.YAML;
023import dev.enola.data.iri.namespace.repo.EmptyNamespaceRepository;
024import dev.enola.data.iri.namespace.repo.NamespaceRepository;
025import dev.enola.data.iri.namespace.repo.NamespaceRepositoryBuilder;
026import dev.enola.model.enola.HasName;
027import dev.enola.model.enola.meta.*;
028import dev.enola.model.enola.meta.Class;
029import dev.enola.thing.repo.ThingRepositoryStore;
030
031import org.jspecify.annotations.Nullable;
032
033import java.io.IOException;
034import java.net.URI;
035import java.util.List;
036import java.util.Map;
037import java.util.concurrent.atomic.AtomicReference;
038import java.util.function.BiConsumer;
039import java.util.function.Consumer;
040
041public class SchemaIO {
042
043    // TODO Use dev.enola.thing.validation and errors.esch.yaml instead of IllegalArgumentException
044
045    public Schema readYAML(ReadableResource resource) throws IOException {
046        // TODO Avoid AtomicReference
047        AtomicReference<Schema> schema = new AtomicReference<>();
048        readYAML(resource, schema1 -> schema.set(schema1));
049        return schema.get();
050    }
051
052    public void readYAML(ReadableResource resource, Consumer<Schema> schemay) throws IOException {
053        var repo = new MetaThingByIdProvider.Builder();
054        YAML.readSingleMap(
055                resource, map -> readSchema(map, repo, schema -> schemay.accept(schema)));
056    }
057
058    private void readSchema(
059            Map<?, ?> map, MetaThingByIdProvider.Builder repo, Consumer<Schema> schemay) {
060        var id = getRemoveString(map, "id");
061        if (id == null) throw new IllegalArgumentException("Mandatory schema id: is missing");
062        var schema = repo.schema(id);
063        schema.name(getRemoveString(map, "name"));
064
065        var iri = getRemoveString(map, "iri");
066        if (iri == null) throw new IllegalArgumentException("Mandatory schema iri: is missing");
067        if (iri.endsWith("/"))
068            throw new IllegalArgumentException("Schema's iri: must NOT end with slash: " + iri);
069        schema.iri(iri);
070        // TODO Re-review this pretty ugly "hack" later...
071        TLC.get(ThingRepositoryStore.class).store(schema);
072
073        schema.description(getRemoveString(map, "description"));
074        schema.java_package(getRemoveString(map, "java:package"));
075
076        var nsr = namespaceRepository(map);
077        // TODO Push onto TLC, and use when encountering namespaced IDs?
078
079        // TODO imports
080
081        var datatypes = getRemoveMap(map, "datatypes");
082        if (datatypes != null) readDatatypes(datatypes, schema, repo);
083
084        var properties = getRemoveMap(map, "properties");
085        if (properties != null) readProperties(properties, schema, repo);
086
087        var classes = getRemoveMap(map, "classes");
088        if (classes != null) readClasses(classes, schema, repo);
089
090        checkEmpty(map);
091        schemay.accept(schema.build());
092    }
093
094    private NamespaceRepository namespaceRepository(Map<?, ?> map) {
095        var prefixes = getRemoveMap(map, ".prefixes");
096        if (prefixes == null) return EmptyNamespaceRepository.INSTANCE;
097        var nsrb = new NamespaceRepositoryBuilder();
098        prefixes.forEach((prefix, iri) -> nsrb.store(prefix.toString(), iri.toString()));
099        return nsrb.build();
100    }
101
102    private void readClasses(
103            Map<?, ?> map, Schema.Builder<?> schema, MetaThingByIdProvider.Builder repo) {
104        readMaps(
105                map,
106                schema,
107                (name, classMap) -> {
108                    var clazz = repo.clazz(schema, name);
109                    readCommon(classMap, clazz, schema.iri());
110
111                    clazz.iriTemplate(getRemoveString(classMap, "iri_template"));
112
113                    var parentsNames = getRemoveList(classMap, "parents");
114                    if (parentsNames != null)
115                        for (var parentName : parentsNames)
116                            clazz.addParent(repo.clazz(schema, parentName.toString()));
117
118                    var idPropertiesMap = asMap(getRemoveMap(classMap, "ids"));
119                    if (idPropertiesMap != null)
120                        readClassProperties(idPropertiesMap, clazz, schema, repo, true);
121
122                    var propertiesMap = asMap(getRemoveMap(classMap, "properties"));
123                    if (propertiesMap != null)
124                        readClassProperties(propertiesMap, clazz, schema, repo, false);
125
126                    checkEmpty(classMap);
127                    schema.addSchemaClass(clazz);
128                });
129    }
130
131    private void readDatatypes(
132            Map<?, ?> map, Schema.Builder<?> schema, MetaThingByIdProvider.Builder repo) {
133        readMaps(
134                map,
135                schema,
136                (name, datatypeMap) -> {
137                    var datatype = repo.datatype(schema, name);
138                    readCommon(datatypeMap, datatype, schema.iri());
139                    datatype.java(getRemoveString(datatypeMap, "java:type"));
140                    datatype.proto(getRemoveString(datatypeMap, "proto"));
141
142                    var xsd = getRemoveString(datatypeMap, "xsd");
143                    // TODO Resolve CURIE, if it is one - but how do we know?
144                    if (xsd != null) datatype.xsd(URI.create(xsd));
145
146                    var parentName = getRemoveString(datatypeMap, "parent");
147                    if (parentName != null) datatype.parent(repo.datatype(schema, parentName));
148
149                    checkEmpty(datatypeMap);
150                    schema.addSchemaDatatype(datatype);
151                });
152    }
153
154    private void readProperties(
155            Map<?, ?> map, Schema.Builder<?> schema, MetaThingByIdProvider.Builder repo) {
156        readMaps(
157                map,
158                schema,
159                (name, propertyMap) -> {
160                    var property = repo.property(schema, name);
161                    readProperty(propertyMap, property, schema, repo);
162                    schema.addSchemaProperty(property);
163                });
164    }
165
166    private void readClassProperties(
167            Map<?, ?> map,
168            Class.Builder<?> clazz,
169            Schema schema,
170            MetaThingByIdProvider.Builder repo,
171            boolean isID) {
172        map.forEach(
173                (name, value) -> {
174                    if (value == null)
175                        // TODO throw new IllegalStateException("TODO Implement Property lookup: " +
176                        // name);
177                        ;
178                    else {
179                        var property = repo.property(schema, name.toString());
180                        if (value instanceof Map<?, ?> propertyMap)
181                            readProperty(propertyMap, property, schema, repo);
182                        else if (value instanceof String propertyDatatypeName)
183                            setDatatype(property, propertyDatatypeName, repo);
184                        else throw new IllegalArgumentException(value.toString());
185                        // TODO property.multiplicity(...)
186                        if (isID) clazz.addClassIdProperty(property);
187                        else clazz.addClassProperty(property);
188                    }
189                });
190    }
191
192    private void setDatatype(
193            Property.Builder<?> property, String datatypeName, MetaThingByIdProvider.Builder repo) {
194        property.datatype(repo.datatype(property.schema(), datatypeName));
195    }
196
197    private void readProperty(
198            Map<?, ?> map,
199            Property.Builder<?> property,
200            Schema schema,
201            MetaThingByIdProvider.Builder repo) {
202        readCommon(map, property, schema.iri());
203        setDatatype(property, getRemoveString(map, "type"), repo);
204        var parentName = getRemoveString(map, "parent");
205        if (parentName != null) property.parent(repo.property(schema, parentName));
206        // TODO getRemoveString(map, "inverse")
207        checkEmpty(map);
208    }
209
210    private void readCommon(Map<?, ?> map, Common.Builder<?> common, String schemaIRI) {
211        common.iri(getRemoveString(map, "iri"));
212        setIRI(common, schemaIRI);
213
214        common.label(getRemoveString(map, "label"));
215        common.description(getRemoveString(map, "description"));
216        // TODO Handle description-md VS description...
217        common.description(getRemoveString(map, "description-md"));
218        // TODO Resolve enola:emoji vs label / description (without enola:) inconsistency...
219        common.emoji(getRemoveString(map, "enola:emoji"));
220    }
221
222    private void readMaps(
223            Map<?, ?> map, Schema.Builder<?> schema, BiConsumer<String, Map<?, ?>> mapper) {
224        map.forEach(
225                (name, object) -> {
226                    var innerMap = asMap(object);
227                    if (innerMap != null) mapper.accept(name.toString(), innerMap);
228                });
229    }
230
231    // TODO Remove, since MetaThingByIdProvider already sets iri() ?
232    private void setIRI(HasName.Builder named, String schemaIRI) {
233        // NOT if (named.iri() == null) {
234        named.iri(schemaIRI + "/" + named.name());
235    }
236
237    private /* TODO @Nullable */ String getRemoveString(Map<?, ?> map, String name) {
238        var value = map.get(name);
239        map.remove(name); // skipcq: JAVA-E1036
240        return value != null ? value.toString() : null;
241    }
242
243    private @Nullable Map<?, ?> getRemoveMap(Map<?, ?> map, String name) {
244        var value = map.get(name);
245        map.remove(name); // skipcq: JAVA-E1036
246        return asMap(value);
247    }
248
249    private @Nullable List<?> getRemoveList(Map<?, ?> map, String name) {
250        var value = map.get(name);
251        map.remove(name); // skipcq: JAVA-E1036
252        return asList(value);
253    }
254
255    private @Nullable Map<?, ?> asMap(@Nullable Object object) {
256        if (object == null) return null;
257        if (object instanceof Map<?, ?> map) return map;
258        throw new IllegalArgumentException(
259                "Should be Map, but is: " + object.getClass() + " " + object);
260    }
261
262    private @Nullable List<?> asList(@Nullable Object object) {
263        if (object == null) return null;
264        if (object instanceof List<?> list) return list;
265        return List.of(object);
266    }
267
268    // TODO Remove when finally fully switching to Thing, where it's *OK* to have extra!
269    private void checkEmpty(Map<?, ?> map) {
270        // Remove a few "special" entries:
271        map.remove("@context"); // skipcq: JAVA-E1036
272        map.remove("$schema"); // skipcq: JAVA-E1036
273
274        if (!map.isEmpty()) {
275            throw new IllegalArgumentException("Unknown properties: " + map);
276        }
277    }
278}