/*
 * 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.syncope.core.persistence.jpa.dao;

import jakarta.persistence.EntityManager;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.syncope.common.lib.types.AttrSchemaType;
import org.apache.syncope.core.persistence.api.attrvalue.PlainAttrValidationManager;
import org.apache.syncope.core.persistence.api.dao.PlainSchemaDAO;
import org.apache.syncope.core.persistence.api.dao.search.AttrCond;
import org.apache.syncope.core.persistence.api.entity.EntityFactory;
import org.apache.syncope.core.persistence.api.entity.PlainAttrValue;
import org.apache.syncope.core.persistence.api.entity.PlainSchema;
import org.apache.syncope.core.persistence.api.utils.RealmUtils;
import org.springframework.data.domain.Sort;

public class PGJPARealmSearchDAO extends AbstractJPARealmSearchDAO {

    protected static final String REGEX_CHARS = "!$()*+.:<=>?[\\]^{|}-";

    protected static String escapeForLikeRegex(final String input) {
        String output = input;
        for (char toEscape : REGEX_CHARS.toCharArray()) {
            output = output.replace(String.valueOf(toEscape), "\\" + toEscape);
        }
        return output.replace("'", "''");
    }

    protected static String escapeIfString(final String value, final boolean isStr) {
        return isStr ? '"' + value.replace("'", "''") + '"' : value;
    }

    public PGJPARealmSearchDAO(
            final EntityManager entityManager,
            final PlainSchemaDAO plainSchemaDAO,
            final EntityFactory entityFactory,
            final PlainAttrValidationManager validator,
            final RealmUtils realmUtils) {

        super(entityManager, plainSchemaDAO, entityFactory, validator, realmUtils);
    }

    @Override
    protected void parseOrderByForPlainSchema(
            final OrderBySupport obs,
            final OrderBySupport.Item item,
            final Sort.Order clause,
            final PlainSchema schema,
            final String fieldName) {

        // keep track of involvement of non-mandatory schemas in the order by clauses
        obs.nonMandatorySchemas = !"true".equals(schema.getMandatoryCondition());

        item.select = fieldName + " -> 0 AS " + fieldName;
        item.where = StringUtils.EMPTY;
        item.orderBy = fieldName + ' ' + clause.getDirection().name();
    }

    protected RealmSearchNode.Leaf filJSONAttrQuery(
            final PlainAttrValue attrValue,
            final PlainSchema schema,
            final AttrCond cond,
            final boolean not) {

        String key = key(schema.getType());
        String valuesPath = schema.isUniqueConstraint() ? "uniqueValue" : "values[*]";

        String value = Optional.ofNullable(attrValue.getDateValue()).
                map(DateTimeFormatter.ISO_OFFSET_DATE_TIME::format).
                orElseGet(cond::getExpression);

        boolean isStr = true;
        boolean lower = false;
        if (schema.getType() == AttrSchemaType.String || schema.getType() == AttrSchemaType.Enum) {
            lower = cond.getType() == AttrCond.Type.IEQ || cond.getType() == AttrCond.Type.ILIKE;
        } else if (schema.getType() != AttrSchemaType.Date) {
            try {
                switch (schema.getType()) {
                    case Long ->
                        Long.valueOf(value);
                    case Double ->
                        Double.valueOf(value);
                    case Boolean -> {
                        if (!("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value))) {
                            throw new IllegalArgumentException();
                        }
                    }
                    default -> {
                    }
                }
                isStr = false;
            } catch (Exception nfe) {
                // ignore — treat as string
            }
        }

        StringBuilder clause = new StringBuilder();
        switch (cond.getType()) {
            case ILIKE:
            case LIKE:
                if (schema.getType() == AttrSchemaType.String || schema.getType() == AttrSchemaType.Enum) {
                    clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                            append(schema.getKey()).append("\").").append(valuesPath).
                            append(" ? (@.").append(key).append(" like_regex \"").
                            append(escapeForLikeRegex(value).replace("%", ".*")).append("\"").
                            append(lower ? " flag \"i\"" : "").append(")')");
                } else {
                    LOG.error("LIKE is only compatible with string or enum schemas");
                    clause.append(ALWAYS_FALSE_CLAUSE);
                }
                break;

            case IEQ:
            case EQ:
            default:
                clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                        append(schema.getKey()).append("\").").append(valuesPath).
                        append(" ? (");
                if (StringUtils.containsAny(value, REGEX_CHARS) || lower) {
                    clause.append("@.").append(key).append(" like_regex \"^").
                            append(escapeForLikeRegex(value).replace("'", "''")).append("$\"");
                } else if (isStr) {
                    clause.append("@.").append(key).append(" == ").append(escapeIfString(value, true));
                } else {
                    // Some datasets can store scalar values as JSON strings; accept both representations.
                    clause.append("@.").append(key).append(" == ").append(escapeIfString(value, false)).
                            append(" || @.").append(key).append(" == ").append(escapeIfString(value, true)).
                            append(" || @.stringValue == ").append(escapeIfString(value, true));
                }
                clause.append(lower ? " flag \"i\"" : "").append(")')");
                break;

            case GE:
                clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                        append(schema.getKey()).append("\").").append(valuesPath).
                        append(" ? (@.").append(key).append(" >= ").
                        append(escapeIfString(value, isStr)).append(")')");
                break;

            case GT:
                clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                        append(schema.getKey()).append("\").").append(valuesPath).
                        append(" ? (@.").append(key).append(" > ").
                        append(escapeIfString(value, isStr)).append(")')");
                break;

            case LE:
                clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                        append(schema.getKey()).append("\").").append(valuesPath).
                        append(" ? (@.").append(key).append(" <= ").
                        append(escapeIfString(value, isStr)).append(")')");
                break;

            case LT:
                clause.append("jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \"").
                        append(schema.getKey()).append("\").").append(valuesPath).
                        append(" ? (@.").append(key).append(" < ").
                        append(escapeIfString(value, isStr)).append(")')");
                break;
        }

        if (not) {
            clause.insert(0, "NOT ");
        }

        return new RealmSearchNode.Leaf(clause.toString());
    }

    @Override
    protected AttrCondQuery getQuery(
            final AttrCond cond,
            final boolean not,
            final CheckResult<AttrCond> checked,
            final List<Object> parameters) {

        if (not) {
            if (cond.getType() == AttrCond.Type.ISNULL) {
                cond.setType(AttrCond.Type.ISNOTNULL);
            } else if (cond.getType() == AttrCond.Type.ISNOTNULL) {
                cond.setType(AttrCond.Type.ISNULL);
            }
        }

        switch (cond.getType()) {
            case ISNOTNULL -> {
                return new AttrCondQuery(true, new RealmSearchNode.Leaf(
                        "jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \""
                        + checked.schema().getKey() + "\")')"));
            }

            case ISNULL -> {
                return new AttrCondQuery(true, new RealmSearchNode.Leaf(
                        "NOT jsonb_path_exists(r.plainAttrs, '$[*] ? (@.schema == \""
                        + checked.schema().getKey() + "\")')"));
            }

            default -> {
                RealmSearchNode.Leaf node;
                if (not && checked.schema().isMultivalue()) {
                    // negate multivalue by wrapping the positive expression in NOT
                    RealmSearchNode.Leaf notNode = filJSONAttrQuery(
                            checked.value(), checked.schema(), cond, false);
                    node = new RealmSearchNode.Leaf("NOT " + notNode.getClause());
                } else {
                    node = filJSONAttrQuery(checked.value(), checked.schema(), cond, not);
                }
                return new AttrCondQuery(true, node);
            }
        }
    }

    @Override
    protected String buildFrom(
            final Set<String> plainSchemas,
            final OrderBySupport obs) {

        StringBuilder clause = new StringBuilder(super.buildFrom(plainSchemas, obs));

        Set<String> schemas = new HashSet<>(plainSchemas);

        if (obs != null) {
            obs.items.forEach(item -> {
                String schema = StringUtils.substringBefore(item.orderBy, ' ');
                if (StringUtils.isNotBlank(schema)) {
                    schemas.add(schema);
                }
            });
        }

        // i.e jsonb_path_query(plainattrs, '$[*] ? (@.schema=="Nome")."values"') AS Nome
        schemas.forEach(schema -> plainSchemaDAO.findById(schema).ifPresent(
                pschema -> clause.append(',').
                        append("jsonb_path_query_array(plainattrs, '$[*] ? (@.schema==\"").
                        append(schema).append("\").").
                        append("\"").append(pschema.isUniqueConstraint() ? "uniqueValue" : "values").append("\"')").
                        append(" AS ").append(schema)));

        return clause.toString();
    }
}
