/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 */
package org.apache.causeway.viewer.restfulobjects.rendering.service.valuerender;

import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.inject.Named;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.util.ClassUtils;

import org.apache.causeway.applib.annotation.PriorityPrecedence;
import org.apache.causeway.applib.value.semantics.ValueDecomposition;
import org.apache.causeway.commons.functional.Try;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.base._Casts;
import org.apache.causeway.commons.internal.base._NullSafe;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.core.metamodel.facets.object.value.ValueSerializer;
import org.apache.causeway.core.metamodel.facets.object.value.ValueSerializer.Format;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.object.ManagedObjects;
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
import org.apache.causeway.core.metamodel.specloader.SpecificationLoader;
import org.apache.causeway.core.metamodel.util.Facets;
import org.apache.causeway.schema.common.v2.ValueType;
import org.apache.causeway.viewer.restfulobjects.applib.CausewayModuleViewerRestfulObjectsApplib;
import org.apache.causeway.viewer.restfulobjects.applib.JsonRepresentation;
import org.apache.causeway.viewer.restfulobjects.rendering.service.valuerender.JsonValueConverter.Context;

import lombok.extern.log4j.Log4j2;

@Service
@Named(CausewayModuleViewerRestfulObjectsApplib.NAMESPACE + ".JsonValueEncoderDefault")
@Priority(PriorityPrecedence.MIDPOINT)
@Qualifier("Default")
@Log4j2
public class JsonValueEncoderServiceDefault implements JsonValueEncoderService {

    private final SpecificationLoader specificationLoader;
    private final Map<Class<?>, JsonValueConverter> converterByClass;

    @Inject
    public JsonValueEncoderServiceDefault(final SpecificationLoader specificationLoader) {
        this.specificationLoader = specificationLoader;
        this.converterByClass = _JsonValueConverters.byClass();
    }

    @Override
    public ManagedObject asAdapter(
            final ObjectSpecification spec,
            final JsonRepresentation valueRepr,
            final JsonValueConverter.Context context) {

        // sanity checks
        if(valueRepr == null) return null;
        if(spec == null) throw new IllegalArgumentException("ObjectSpecification is required");
        if(!spec.isValue()) throw new IllegalArgumentException("Representation must be of a value");

        // primitive representation checks (ignoring value-semantics)
        var valueClass = spec.getCorrespondingClass();
        if(ClassUtils.isPrimitiveOrWrapper(valueClass)) {
            var primitiveWrapper = ClassUtils.resolvePrimitiveIfNecessary(valueClass);
            if(Boolean.class.equals(primitiveWrapper)) {
                booleanRepresentationCheck(valueRepr);
            } else if(Character.class.equals(primitiveWrapper)) {
                characterRepresentationCheck(valueRepr);
            }
        }

        // handle composite value types (requires a ValueSemanticsProvider for the valueClass to be registered with Spring)
        if(spec.isCompositeValue()) {
            _Assert.assertTrue(valueRepr.isString(), ()->"expected to receive a String originating from ValueDecomposition#stringify");
            var valueFacet = spec.valueFacetElseFail();
            var valSemantics = valueFacet.selectDefaultSemantics().orElseThrow();
            var valDecomposition = ValueDecomposition.destringify(ValueType.COMPOSITE, valueRepr.asString());
            var pojo = valSemantics.compose(valDecomposition);
            return ManagedObject.value(spec, pojo);
        }

        return asAdapterUsingStaticConverters(spec, valueRepr, context)
                .orElseGet(()->asAdapterUsingValueSemantics(spec, valueRepr, context));
    }

    private ManagedObject asAdapterUsingValueSemantics(
            final ObjectSpecification spec,
            final JsonRepresentation valueRepr,
            final JsonValueConverter.Context context) {
        var valueClass = spec.getCorrespondingClass();
        var valueSerializer = Facets.valueSerializerElseFail(spec, valueClass);

        // handle values that are represented as maps
        if(valueRepr.isMap()) {
            var json = valueRepr.asJsonNode().toString();
            var pojo = valueSerializer.destring(Format.JSON, json);
            return ManagedObject.value(spec, pojo);
        }

        // best effort: try 'String' repr. type
        return recoverPojoFromStringElseFail(valueRepr, valueSerializer)
                .map(pojo->ManagedObject.value(spec, pojo))
                .orElseGet(()->ManagedObject.empty(spec));
    }

    /**
     * Uses legacy converters overriding value-semantics.
     */
    private Optional<ManagedObject> asAdapterUsingStaticConverters(
            final ObjectSpecification spec,
            final JsonRepresentation valueRepr,
            final JsonValueConverter.Context context) {

        var valueClass = spec.getCorrespondingClass();
        return Optional.ofNullable(converterByClass
                .get(ClassUtils.resolvePrimitiveIfNecessary(valueClass)))
            .map(jsonValueConverter->jsonValueConverter.recoverValueAsPojo(valueRepr, context))
            .map(valueAsPojo->ManagedObject.value(spec, valueAsPojo));
    }

    /**
     * Returns the recovered nullable pojo, wrapped as optional.
     * @throws IllegalArgumentException if cannot be parsed as String
     */
    private static Optional<Object> recoverPojoFromStringElseFail(
            final JsonRepresentation valueRepr,
            final ValueSerializer<?> valueSerializer) {
        if (valueRepr.isString()) {
            var recoveredValue = Try.call(()->
                    valueSerializer.destring(Format.JSON, valueRepr.asString()))
                    .mapFailure(ex->_Exceptions
                            .illegalArgument(ex, "Unable to parse value %s as from String representation", valueRepr))
                    .valueAsNullableElseFail();
            return Optional.ofNullable(recoveredValue);
        }
        throw _Exceptions.illegalArgument("Unable to parse value %s from String representation"
                + " (using 'String' as a fallback attempt)", valueRepr);
    }

    @Override
    public void appendValueAndFormat(
            final ManagedObject valueAdapter,
            final JsonRepresentation repr,
            final Context context) {

        var valueSpec = valueAdapter.objSpec();
        var valueClass = valueSpec.getCorrespondingClass();
        var jsonValueConverter = converterByClass.get(valueClass);
        if(jsonValueConverter != null) {
            jsonValueConverter.appendValueAndFormat(valueAdapter, context, repr);
            return;
        } else {
            final Optional<ValueDecomposition> valueDecompositionIfAny = decompose(valueAdapter);
            if(valueDecompositionIfAny.isPresent()) {
                var valueDecomposition = valueDecompositionIfAny.get();
                var valueAsJson = valueDecomposition.toJson();
                valueDecomposition.accept(
                        simple->{
                            // special treatment for BLOB/CLOB/ENUM as these are better represented by a map
                            if(simple.getType() == ValueType.BLOB
                                    || simple.getType() == ValueType.CLOB
                                    || simple.getType() == ValueType.ENUM) {

                                /* Don't move this line of code up before
                                 * the accept call (to attempt code de-duplication)!
                                 * It will fail the 'else' path. */
                                var decompRepr = JsonRepresentation.jsonAsMap(valueAsJson);
                                // amend emums with "enumTitle"
                                if(simple.getType() == ValueType.ENUM) {
                                    decompRepr.mapPutString("enumTitle", valueAdapter.getTitle());
                                }
                                repr.mapPutJsonRepresentation("value", decompRepr);
                                appendFormats(repr, null, simple.getType().value(), context.isSuppressExtensions());
                            } else {
                                // using string representation from value semantics
                                repr.mapPutString("value", valueAsJson);
                                appendFormats(repr, "string", simple.getType().value(), context.isSuppressExtensions());
                            }
                        },
                        tuple->{
                            var decompRepr = JsonRepresentation.jsonAsMap(valueAsJson);
                            repr.mapPutJsonRepresentation("value", decompRepr);
                            var typeTupleAsFormat = "{"
                                    + tuple.getElements().stream()
                                        .map(el->el.getType().value())
                                        .collect(Collectors.joining(","))
                                    + "}";

                            appendFormats(repr, null, typeTupleAsFormat, context.isSuppressExtensions());
                        });
            } else {
                appendNullAndFormat(repr, context.isSuppressExtensions());
            }
        }
    }

    private static Optional<ValueDecomposition> decompose(final ManagedObject valueAdapter) {
        if(ManagedObjects.isNullOrUnspecifiedOrEmpty(valueAdapter)) {
            return Optional.empty();
        }
        var valueClass = valueAdapter.objSpec().getCorrespondingClass();
        var decompositionIfAny = Facets.valueDefaultSemantics(valueAdapter.objSpec(), valueClass)
                .map(composer->composer.decompose(_Casts.uncheckedCast(valueAdapter.getPojo())));
        if(decompositionIfAny.isEmpty()) {
            var valueSpec = valueAdapter.objSpec();
            log.warn("{Could not resolve a ValueComposer for {}, "
                    + "falling back to rendering as 'null'. "
                    + "Make sure the framework has access to a ValueSemanticsProvider<{}> "
                    + "that implements ValueComposer<{}>}",
                    valueSpec.logicalTypeName(),
                    valueSpec.getCorrespondingClass().getSimpleName(),
                    valueSpec.getCorrespondingClass().getSimpleName());
        }
        return decompositionIfAny;
    }

    @Override
    @Nullable
    public Object asObject(final @NonNull ManagedObject adapter, final JsonValueConverter.Context context) {

        var objectSpec = adapter.objSpec();
        var cls = objectSpec.getCorrespondingClass();

        var jsonValueConverter = converterByClass.get(cls);
        if(jsonValueConverter != null) {
            return jsonValueConverter.asObject(adapter, context);
        }

        // else
        return Facets.valueSerializerElseFail(objectSpec, cls)
                .enstring(Format.JSON, _Casts.uncheckedCast(adapter.getPojo()));
    }

    // -- PRIMITIVE CHECKS

    private void booleanRepresentationCheck(final JsonRepresentation valueRepr) {
        if(!valueRepr.isBoolean()) {
            throw new IllegalArgumentException("Representation must be boolean");
        }
    }

    private void characterRepresentationCheck(final JsonRepresentation valueRepr) {
        if(valueRepr.isString()
                && _NullSafe.size(valueRepr.asString()) <= 1) {
            return; // check is ok
        }
        throw new IllegalArgumentException("Representation must be a single character string");
    }

}
