/*
 *  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.applib.services.appfeat;

import java.io.Serializable;
import java.util.Comparator;
import java.util.function.Consumer;

import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsFirst;

import org.jspecify.annotations.Nullable;

import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.ObjectSupport;
import org.apache.causeway.applib.annotation.Programmatic;
import org.apache.causeway.applib.annotation.Value;
import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.applib.util.Equality;
import org.apache.causeway.applib.util.Hashing;
import org.apache.causeway.applib.util.ObjectContracts;
import org.apache.causeway.applib.util.TitleBuffer;
import org.apache.causeway.applib.util.ToString;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.collections._Lists;
import org.apache.causeway.commons.internal.exceptions._Exceptions;

import lombok.Getter;
import org.jspecify.annotations.NonNull;
import lombok.Synchronized;

/**
 * Value type representing a namespace, type or member.
 * <p>
 * This value is {@link Comparable}, the implementation of which considers
 * {@link #getSort() (feature) sort}, {@link #getNamespace() namespace},
 * {@link #getTypeSimpleName() type simple name} and {@link #getLogicalMemberName() member-logical-name}.
 * <p>
 * If the represented member is an <i>action</i>, then {@link #getLogicalMemberName() member-logical-name}
 * must <b>not</b> include any parameter list or parentheses.
 * Consequently method overloading is not supported.
 * <p>
 * If there is a member name clash involving an <i>action</i> and an <i>association</i>,
 * then consequently any permissions defined automatically apply to both and one cannot separate
 * these.
 *
 * @since 1.x revised for 2.0 {@index}
 */
@Value
public class ApplicationFeatureId
implements
    Comparable<ApplicationFeatureId>,
    Serializable {

    private static final long serialVersionUID = 1L;

    // -- CONSTANTS

    public static final ApplicationFeatureId NAMESPACE_DEFAULT = newNamespace("default");

    // -- FACTORY METHODS

    public static ApplicationFeatureId fromIdentifier(final @NonNull Identifier identifier) {

        var logicalTypeName = identifier.logicalTypeName();

        if(identifier.type().isClass()) {
            return newType(logicalTypeName);
        }
        return newMember(logicalTypeName, identifier.memberLogicalName());
    }

    public static ApplicationFeatureId newFeature(
            final @NonNull ApplicationFeatureSort featureSort,
            final @NonNull String qualifiedLogicalName) {

        switch (featureSort) {
        case NAMESPACE:
            return newNamespace(qualifiedLogicalName);
        case TYPE:
            return newType(qualifiedLogicalName);
        case MEMBER:
            return newMember(qualifiedLogicalName);
        }
        throw _Exceptions.illegalArgument("Unknown feature sort '%s'", featureSort);
    }

    public static ApplicationFeatureId newFeature(
            final @NonNull  String namespace,
            final @Nullable String logicalTypeSimpleName,
            final @Nullable String memberName) {

        if(logicalTypeSimpleName == null) {
            return newNamespace(namespace);
        }
        var logicalTypeName = namespace + "." + logicalTypeSimpleName;
        if(memberName == null) {
            return newType(logicalTypeName);
        }
        return newMember(logicalTypeName, memberName);
    }

    public static ApplicationFeatureId newNamespace(final String namespace) {
        var featureId = new ApplicationFeatureId(ApplicationFeatureSort.NAMESPACE);
        featureId.namespace = namespace;
        featureId.typeSimpleName = null;
        featureId.logicalMemberName = null;
        return featureId;
    }

    /**
     * Creates a new {@link ApplicationFeatureId} based on the {@link LogicalType#getNamespace() namespace}
     * of the provided {@link LogicalType}.
     *
     * @param logicalType - from which the namespace is inferred.
     */
    public static ApplicationFeatureId newNamespace(final LogicalType logicalType) {
        return newNamespace(logicalType.namespace());
    }

    public static ApplicationFeatureId newType(final String logicalTypeName) {
        var featureId = new ApplicationFeatureId(ApplicationFeatureSort.TYPE);
        initType(featureId, logicalTypeName);
        return featureId;
    }

    public static ApplicationFeatureId newType(final LogicalType logicalType) {
        return newType(logicalType.logicalName());
    }

    public static ApplicationFeatureId newMember(final LogicalType logicalType, final String memberLogicalName) {
        return newMember(logicalType.logicalName(), memberLogicalName);
    }

    public static ApplicationFeatureId newMember(final String logicalTypeName, final String memberLogicalName) {
        var featureId = new ApplicationFeatureId(ApplicationFeatureSort.MEMBER);
        initType(featureId, logicalTypeName);
        initMember(featureId, memberLogicalName);
        return featureId;
    }

    public static ApplicationFeatureId newMember(final String fullyQualifiedLogicalName) {
        var featureId = new ApplicationFeatureId(ApplicationFeatureSort.MEMBER);
        var i = fullyQualifiedLogicalName.lastIndexOf("#");
        if(i == -1) {
            throw new IllegalArgumentException("Malformed, expected a '#': " + fullyQualifiedLogicalName);
        }
        var logicalTypeName = fullyQualifiedLogicalName.substring(0, i);
        var memberName = fullyQualifiedLogicalName.substring(i + 1);
        initType(featureId, logicalTypeName);
        initMember(featureId, memberName);
        return featureId;
    }

    // -- FACTORY HELPERS

    private static void initType(final ApplicationFeatureId featureId, final String fullyQualifiedName) {
        final int i = fullyQualifiedName.lastIndexOf(".");
        if(i != -1) {
            featureId.namespace = fullyQualifiedName.substring(0, i);
            featureId.typeSimpleName = fullyQualifiedName.substring(i+1);
        } else {
            featureId.namespace = "";
            featureId.typeSimpleName = fullyQualifiedName;
        }

        // guard against empty namespace; there should be a meta-model validator that already catched that
        if(_Strings.isEmpty(featureId.namespace)) {
            throw _Exceptions.illegalArgument(
                    "fullyQualifiedName '%s' must include a non-empty namespace", fullyQualifiedName);
        }

        featureId.logicalMemberName = null;
    }

    private static void initMember(final ApplicationFeatureId featureId, final @Nullable String memberLogicalName) {
        featureId.logicalMemberName = stripOffParamsIfAny(memberLogicalName); // just in case
    }

    private static String stripOffParamsIfAny(final @Nullable String name) {
        if(_Strings.isEmpty(name)) {
            return name;
        }
        final int paramListStartIndex = name.indexOf('(');
        return paramListStartIndex>-1
                ? name.substring(0, paramListStartIndex)
                : name;
    }

    // -- CONSTRUCTOR

    private ApplicationFeatureId(final ApplicationFeatureSort sort) {
        this.sort = sort;
    }

    // -- TITLE

    /**
     * having a title() method (rather than using @Title annotation) is necessary as a workaround to be able to use
     * wrapperFactory#unwrap(...) method, which is otherwise broken in Causeway 1.6.0
     */
    @ObjectSupport public String title() {
        var buf = new TitleBuffer();
        buf.append(getFullyQualifiedName());
        return buf.toString();
    }

    // -- PROPERTIES

    @Getter final @NonNull ApplicationFeatureSort sort;

    /**
     * The {@link ApplicationFeatureId id} of the member's logical type.
     */
    public ApplicationFeatureId getParentTypeFeatureId() {
        _Assert.assertTrue(sort.isMember());
        final String logicalTypeName = this.getNamespace() + "." + getTypeSimpleName();
        return newType(logicalTypeName);
    }

    // -- PROPERTIES - NON UI

    @Programmatic
    @Getter private String namespace;

    /**
     * The type name, eg &quot;Customer&quot;.
     *
     * <p>
     * Will be {@code null} if this feature is a {@link ApplicationFeatureSort#NAMESPACE namespace}.
     * </p>
     */
    @Programmatic
    @Getter private String typeSimpleName;

    /**
     * Logical (simple) name of the member (in case of actions not including the parameter list).
     * (This works because method overloading of actions is not supported by the framework).
     *
     * <p>
     * If there is a member name clash involving an <i>action</i> and an <i>association</i>,
     * then consequently any permissions defined automatically apply to both and one cannot separate
     * these.
     * </p>
     *
     * <p>
     * Will be {@code null} if this feature is not a {@link ApplicationFeatureSort#MEMBER member}.
     * </p>
     */
    @Programmatic
    @Getter private String logicalMemberName;

    @Programmatic
    public String getFullyQualifiedName() {
        var buf = new StringBuilder();
        buf.append(getNamespace());
        if(getTypeSimpleName() != null) {
            buf.append(".").append(getTypeSimpleName());
        }
        if(getLogicalMemberName() != null) {
            buf.append("#").append(getLogicalMemberName());
        }
        return buf.toString();
    }

    @Programmatic
    public String getLogicalTypeName() {
        if (getTypeSimpleName() == null) {
            return null;
        }
        var buf = new StringBuilder();
        if(!_Strings.isNullOrEmpty(getNamespace())) {
            buf.append(getNamespace()).append(".");
        }
        buf.append(getTypeSimpleName());

        return buf.toString();
    }

    /**
     * The {@link ApplicationFeatureId id} of the parent package of this
     * class or package.
     */
    @Programmatic
    public ApplicationFeatureId getParentNamespaceFeatureId() {

        _Assert.assertFalse(sort.isMember());

        if(sort.isType()) {
            return ApplicationFeatureId.newNamespace(getNamespace());
        } else {
            var namespace = getNamespace(); // eg aaa.bbb.ccc
            if(!namespace.contains(".")) {
                return null; // parent is root
            }
            final int cutOffPos = namespace.lastIndexOf('.');
            final String parentPackageName = namespace.substring(0, cutOffPos);
            return newNamespace(parentPackageName);
        }
    }

    // -- ENCODING / DECODING

    @Programmatic
    public String stringify() {
        return sort.name() + ":" + getFullyQualifiedName();
    }

    /**
     * Round-trip with {@link #stringify()}
     */
    public static ApplicationFeatureId parse(final String stringified) {
        return _Strings.splitThenApplyRequireNonEmpty(stringified, ":", (sort, fqn)->
            newFeature(ApplicationFeatureSort.valueOf(sort), fqn))
        .orElseThrow(()->_Exceptions.illegalArgument("cannot parse feature-id '%s'", stringified));
    }

    @Programmatic
    public String asEncodedString() {
        return _Strings.base64UrlEncode(stringify());
    }

    /**
     * Round-trip with {@link #asEncodedString()}
     */
    public static ApplicationFeatureId parseEncoded(final String encodedString) {
        return parse(_Strings.base64UrlDecode(encodedString));
    }

    // -- pathIds, parentIds

    private transient Can<ApplicationFeatureId> pathIds;

    @Programmatic @Synchronized
    public Can<ApplicationFeatureId> getPathIds() {
        if(pathIds==null) {
            pathIds = pathIds(this);
        }
        return pathIds;
    }

    @Programmatic
    public Can<ApplicationFeatureId> getParentFeatureIds() {
        var parent = getParentFeatureId();
        return parent!=null
                ? getParentFeatureId().getPathIds()
                : Can.empty();
    }

    private ApplicationFeatureId getParentFeatureId() {
        return sort.isMember()
                ? getParentTypeFeatureId()
                : getParentNamespaceFeatureId();
    }

    private static Can<ApplicationFeatureId> pathIds(final ApplicationFeatureId featureId) {
        var featureIds = _Lists.<ApplicationFeatureId>newArrayList();
        visitSelfAndParents(featureId, featureIds::add);
        return Can.ofCollection(featureIds);
    }

    private static void visitSelfAndParents(
            final ApplicationFeatureId featureId,
            final Consumer<ApplicationFeatureId> onNext) {
        if(featureId != null) {
            onNext.accept(featureId);
            visitSelfAndParents(featureId.getParentFeatureId(), onNext);
        }
    }

    public boolean contains(final @NonNull ApplicationFeatureId otherFeatureId) {
        // be aware of this seemingly inverted semantics eg.:
        // 'causeway' contains 'causeway.xxx'
        return otherFeatureId.getPathIds().contains(this);
    }

    // -- OBJECT CONTRACT

    private static final Comparator<ApplicationFeatureId> bySort =
            comparing(ApplicationFeatureId::getSort, nullsFirst(naturalOrder()));
    private static final Comparator<ApplicationFeatureId> byNamespace =
            comparing(ApplicationFeatureId::getNamespace, nullsFirst(naturalOrder()));
    private static final Comparator<ApplicationFeatureId> byTypeSimpleName =
            comparing(ApplicationFeatureId::getTypeSimpleName, nullsFirst(naturalOrder()));
    private static final Comparator<ApplicationFeatureId> byMemberName =
            comparing(ApplicationFeatureId::getLogicalMemberName, nullsFirst(naturalOrder()));

    private static final Comparator<ApplicationFeatureId> comparator =
            Comparator.nullsFirst(bySort)
            .thenComparing(byNamespace)
            .thenComparing(byTypeSimpleName)
            .thenComparing(byMemberName);

    private static final Equality<ApplicationFeatureId> equality =
            ObjectContracts.checkEquals(ApplicationFeatureId::getSort)
            .thenCheckEquals(ApplicationFeatureId::getNamespace)
            .thenCheckEquals(ApplicationFeatureId::getTypeSimpleName)
            .thenCheckEquals(ApplicationFeatureId::getLogicalMemberName);

    private static final Hashing<ApplicationFeatureId> hashing =
            ObjectContracts.hashing(ApplicationFeatureId::getSort)
            .thenHashing(ApplicationFeatureId::getNamespace)
            .thenHashing(ApplicationFeatureId::getTypeSimpleName)
            .thenHashing(ApplicationFeatureId::getLogicalMemberName);

    private static final ToString<ApplicationFeatureId> toString =
            ObjectContracts.toString("sort", ApplicationFeatureId::getSort)
            .thenToString("namespace", ApplicationFeatureId::getNamespace)
            .thenToStringOmitIfAbsent("typeSimpleName", ApplicationFeatureId::getTypeSimpleName)
            .thenToStringOmitIfAbsent("memberName", ApplicationFeatureId::getLogicalMemberName);

    @Override
    public int compareTo(final ApplicationFeatureId other) {
        return comparator.compare(this, other);
    }

    @Override
    public boolean equals(final Object o) {
        return equality.equals(this, o);
    }

    @Override
    public int hashCode() {
        return hashing.hashCode(this);
    }

    @Override
    public String toString() {
        return toString.toString(this);
    }

    // -- WITHERS

    /**
     * Returns a new instance that is a clone of this, except for the namespace,
     * which is taken from the argument.
     * @param namespace
     */
    public ApplicationFeatureId withNamespace(final @NonNull String namespace) {
        return newFeature(namespace, this.getTypeSimpleName(), this.getLogicalMemberName());
    }

    /**
     * Returns a new instance that is a clone of this feature (expected to be a
     * {@link ApplicationFeatureSort#MEMBER member} or {@link ApplicationFeatureSort#TYPE type}), but with the
     * logicalTypeName taken from the argument.
     *
     * <p>
     *     If this feature is instead a {@link ApplicationFeatureSort#NAMESPACE namespace} then the feature is
     *     returned unchanged.
     * </p>
     *
     * <p>
     *     Note: if the feature is a {@link ApplicationFeatureSort#TYPE type}, then this method will be the same as
     *     if {@link ApplicationFeatureId#newType(String)} had been used to create a new instance.  The method exists
     *     though to provide a standardized way of transforming {@link ApplicationFeatureId}s where possible, without
     *     the caller having to worry as to what {@link #getSort() sort} of feature it is dealing with.
     * </p>
     *
     * @param logicalTypeName
     */
    public ApplicationFeatureId withLogicalTypeName(final @NonNull String logicalTypeName) {
        switch (getSort()) {
            case MEMBER:
                return newMember(logicalTypeName, this.getLogicalMemberName());
            case TYPE:
                return newType(logicalTypeName);
            case NAMESPACE:
            default:
                return this;
        }
    }

}
