namespace Hims.Infrastructure.Repositories.SqlGenerator
{
    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.ComponentModel.DataAnnotations.Schema;
    using System.Linq;
    using System.Linq.Expressions;
    using System.Reflection;
    using System.Text;
    using Domain.Repositories.SqlGenerator;
    using Shared.Dapper.Attributes;
    using Shared.Dapper.Attributes.Joins;
    using Shared.Dapper.Attributes.LogicalDelete;
    using Shared.Dapper.Extensions;
    using Shared.Dapper.SqlGenerator;

    /// <inheritdoc />
    public sealed class SqlGenerator<TEntity> : ISqlGenerator<TEntity> where TEntity : class
    {
        /// <inheritdoc />
        public SqlGenerator(SqlProvider sqlProvider, bool useQuotationMarks = false)
            : this(new SqlGeneratorConfig { SqlProvider = sqlProvider, UseQuotationMarks = useQuotationMarks })
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="SqlGenerator{TEntity}"/> class.
        /// </summary>
        /// <param name="sqlGeneratorConfig">
        /// The sql generator config.
        /// </param>
        public SqlGenerator(SqlGeneratorConfig sqlGeneratorConfig)
        {
            // Order is important
            this.InitProperties();
            this.InitConfig(sqlGeneratorConfig);
            this.InitLogicalDeleted();
        }

        /// <summary>
        /// The query type.
        /// </summary>
        private enum QueryType
        {
            /// <summary>
            /// The select.
            /// </summary>
            Select,

            /// <summary>
            /// The delete.
            /// </summary>
            Delete,

            /// <summary>
            /// The update.
            /// </summary>
            Update
        }

        /// <inheritdoc />
        public PropertyInfo[] AllProperties { get; private set; }

        /// <inheritdoc />
        public bool HasUpdatedAt => this.UpdatedAtProperty != null;

        /// <inheritdoc />
        public PropertyInfo UpdatedAtProperty { get; private set; }

        /// <inheritdoc />
        public SqlPropertyMetadata UpdatedAtPropertyMetadata { get; private set; }

        /// <inheritdoc />
        public bool IsIdentity => this.IdentitySqlProperty != null;

        /// <inheritdoc />
        public string TableName { get; private set; }

        /// <inheritdoc />
        public string TableSchema { get; private set; }

        /// <inheritdoc />
        public SqlPropertyMetadata IdentitySqlProperty { get; private set; }

        /// <inheritdoc />
        public SqlPropertyMetadata[] KeySqlProperties { get; private set; }

        /// <inheritdoc />
        public SqlPropertyMetadata[] SqlProperties { get; private set; }

        /// <inheritdoc />
        public SqlJoinPropertyMetadata[] SqlJoinProperties { get; private set; }

        /// <inheritdoc />
        public SqlGeneratorConfig Config { get; private set; }

        /// <inheritdoc />
        public bool LogicalDelete { get; private set; }

        /// <inheritdoc />
        public string StatusPropertyName { get; private set; }

        /// <inheritdoc />
        public object LogicalDeleteValue { get; private set; }

        /// <inheritdoc />
        public SqlQuery GetCount(Expression<Func<TEntity, bool>> predicate)
        {
            var sqlQuery = new SqlQuery();
            sqlQuery.SqlBuilder.Append("SELECT COUNT(*) FROM " + this.TableName + " ");

            this.AppendWherePredicateQuery(sqlQuery, predicate, QueryType.Select);

            return sqlQuery;
        }

        /// <inheritdoc />
        public SqlQuery GetCount(Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, object>> distinctField)
        {
            var propertyName = ExpressionHelper.GetPropertyName(distinctField);
            var property = this.SqlProperties.First(x => x.PropertyName == propertyName);
            var sqlQuery = this.InitBuilderCountWithDistinct(property);

            sqlQuery.SqlBuilder.Append(" FROM " + this.TableName + " ");

            this.AppendWherePredicateQuery(sqlQuery, predicate, QueryType.Select);

            return sqlQuery;
        }

        /// <inheritdoc />
        public SqlQuery GetSelectFirst(Expression<Func<TEntity, bool>> predicate, params Expression<Func<TEntity, object>>[] includes) => this.GetSelect(predicate, true, includes);

        /// <inheritdoc />
        public SqlQuery GetSelectAll(Expression<Func<TEntity, bool>> predicate, params Expression<Func<TEntity, object>>[] includes) => this.GetSelect(predicate, false, includes);

        /// <inheritdoc />
        public SqlQuery GetSelectById(object id, params Expression<Func<TEntity, object>>[] includes)
        {
            if (this.KeySqlProperties.Length != 1)
            {
                throw new NotSupportedException("This method support only 1 key");
            }

            var keyProperty = this.KeySqlProperties[0];

            var sqlQuery = this.InitBuilderSelect(true);

            if (includes.Any())
            {
                var joinsBuilder = this.AppendJoinToSelect(sqlQuery, includes);
                sqlQuery.SqlBuilder.Append(" FROM " + this.TableName + " ");
                sqlQuery.SqlBuilder.Append(joinsBuilder);
            }
            else
            {
                sqlQuery.SqlBuilder.Append(" FROM " + this.TableName + " ");
            }

            IDictionary<string, object> dictionary = new Dictionary<string, object>
                                                         {
                                                             { keyProperty.PropertyName, id }
                                                         };
            sqlQuery.SqlBuilder.Append("WHERE " + this.TableName + "." + keyProperty.ColumnName + " = @" + keyProperty.PropertyName + " ");

            if (this.LogicalDelete)
            {
                sqlQuery.SqlBuilder.Append("AND " + this.TableName + "." + this.StatusPropertyName + " != " + this.LogicalDeleteValue + " ");
            }

            if (this.Config.SqlProvider == SqlProvider.MySQL || this.Config.SqlProvider == SqlProvider.PostgreSQL)
            {
                sqlQuery.SqlBuilder.Append("LIMIT 1");
            }

            sqlQuery.SetParam(dictionary);
            return sqlQuery;
        }

        /// <inheritdoc />
        public SqlQuery GetSelectBetween(object from, object to, Expression<Func<TEntity, object>> btwField, Expression<Func<TEntity, bool>> expression = null)
        {
            var fieldName = ExpressionHelper.GetPropertyName(btwField);
            var columnName = this.SqlProperties.First(x => x.PropertyName == fieldName).ColumnName;
            var query = this.GetSelectAll(expression);

            query.SqlBuilder.Append((expression == null && !this.LogicalDelete ? "WHERE" : "AND") + " " + this.TableName + "." + columnName + " BETWEEN '" + from + "' AND '" + to + "'");

            return query;
        }

        /// <inheritdoc />
        public SqlQuery GetDelete(TEntity entity)
        {
            var sqlQuery = new SqlQuery();
            var whereSql = " WHERE " + string.Join(" AND ", this.KeySqlProperties.Select(p => this.TableName + "." + p.ColumnName + " = @" + p.PropertyName));

            if (!this.LogicalDelete)
            {
                sqlQuery.SqlBuilder.Append("DELETE FROM " + this.TableName + whereSql);
            }
            else
            {
                sqlQuery.SqlBuilder.Append("UPDATE " + this.TableName + " SET " + this.StatusPropertyName + " = " + this.LogicalDeleteValue);

                if (this.HasUpdatedAt)
                {
                    this.UpdatedAtProperty.SetValue(entity, DateTime.UtcNow);
                    sqlQuery.SqlBuilder.Append(", " + this.UpdatedAtPropertyMetadata.ColumnName + " = @" + this.UpdatedAtPropertyMetadata.PropertyName);
                }

                sqlQuery.SqlBuilder.Append(whereSql);
            }

            sqlQuery.SetParam(entity);
            return sqlQuery;
        }

        /// <inheritdoc />
        public SqlQuery GetDelete(Expression<Func<TEntity, bool>> predicate)
        {
            var sqlQuery = new SqlQuery();

            if (!this.LogicalDelete)
            {
                sqlQuery.SqlBuilder.Append("DELETE FROM " + this.TableName + " ");
            }
            else
            {
                sqlQuery.SqlBuilder.Append("UPDATE " + this.TableName + " SET " + this.StatusPropertyName + " = " + this.LogicalDeleteValue);
                sqlQuery.SqlBuilder.Append(this.HasUpdatedAt ? ", " + this.UpdatedAtPropertyMetadata.ColumnName + " = @" + this.UpdatedAtPropertyMetadata.PropertyName + " " : " ");
            }

            this.AppendWherePredicateQuery(sqlQuery, predicate, QueryType.Delete);
            return sqlQuery;
        }

        /// <inheritdoc />
        public SqlQuery GetInsert(TEntity entity)
        {
            var properties = (this.IsIdentity ? this.SqlProperties.Where(p => !p.PropertyName.Equals(this.IdentitySqlProperty.PropertyName, StringComparison.OrdinalIgnoreCase)) : this.SqlProperties).ToList();

            if (this.HasUpdatedAt)
            {
                this.UpdatedAtProperty.SetValue(entity, DateTime.UtcNow);
            }

            var query = new SqlQuery(entity);

            query.SqlBuilder.Append(
                "INSERT INTO " + this.TableName
                + " (" + string.Join(", ", properties.Select(p => p.ColumnName)) + ")"
                + " VALUES (" + string.Join(", ", properties.Select(p => "@" + p.PropertyName)) + ")"); // values

            if (!this.IsIdentity)
            {
                return query;
            }

            switch (this.Config.SqlProvider)
            {
                case SqlProvider.MSSQL:
                    query.SqlBuilder.Append(" SELECT SCOPE_IDENTITY() AS " + this.IdentitySqlProperty.ColumnName);
                    break;

                case SqlProvider.MySQL:
                    query.SqlBuilder.Append("; SELECT CONVERT(LAST_INSERT_ID(), SIGNED INTEGER) AS " + this.IdentitySqlProperty.ColumnName);
                    break;

                case SqlProvider.PostgreSQL:
                    query.SqlBuilder.Append(" RETURNING " + this.IdentitySqlProperty.ColumnName);
                    break;

                default:
                    throw new ArgumentOutOfRangeException();
            }

            return query;
        }

        /// <inheritdoc />
        public SqlQuery GetBulkInsert(IEnumerable<TEntity> entities)
        {
            var entitiesArray = entities as TEntity[] ?? entities.ToArray();
            if (!entitiesArray.Any())
            {
                throw new ArgumentException("collection is empty");
            }

            var entityType = entitiesArray[0].GetType();

            var properties = (this.IsIdentity ? this.SqlProperties.Where(p => !p.PropertyName.Equals(this.IdentitySqlProperty.PropertyName, StringComparison.OrdinalIgnoreCase)) : this.SqlProperties).ToList();

            var query = new SqlQuery();

            var values = new List<string>();
            var parameters = new Dictionary<string, object>();

            for (var i = 0; i < entitiesArray.Length; i++)
            {
                if (this.HasUpdatedAt)
                {
                    this.UpdatedAtProperty.SetValue(entitiesArray[i], DateTime.UtcNow);
                }

                foreach (var property in properties)
                {
                    parameters.Add(property.PropertyName + i, entityType.GetProperty(property.PropertyName)?.GetValue(entitiesArray[i], null));
                }

                var i1 = i;
                values.Add("(" + string.Join(", ", properties.Select(p => "@" + p.PropertyName + i1)) + ")");
            }

            query.SqlBuilder.Append(
                "INSERT INTO " + this.TableName
                + " (" + string.Join(", ", properties.Select(p => p.ColumnName)) + ")"
                + " VALUES " + string.Join(",", values)); // values

            query.SetParam(parameters);

            return query;
        }

        /// <inheritdoc />
        public SqlQuery GetUpdate(TEntity entity)
        {
            var properties = this.SqlProperties.Where(p => !this.KeySqlProperties.Any(k => k.PropertyName.Equals(p.PropertyName, StringComparison.OrdinalIgnoreCase)) && !p.IgnoreUpdate).ToArray();
            if (!properties.Any())
            {
                throw new ArgumentException("Can't update without [Key]");
            }

            if (this.HasUpdatedAt)
            {
                this.UpdatedAtProperty.SetValue(entity, DateTime.UtcNow);
            }

            var query = new SqlQuery(entity);
            query.SqlBuilder.Append("UPDATE " + this.TableName + " SET " + string.Join(", ", properties.Select(p => p.ColumnName + " = @" + p.PropertyName))
                + " WHERE " + string.Join(" AND ", this.KeySqlProperties.Where(p => !p.IgnoreUpdate).Select(p => p.ColumnName + " = @" + p.PropertyName)));

            return query;
        }

        /// <inheritdoc />
        public SqlQuery GetUpdate(Expression<Func<TEntity, bool>> predicate, TEntity entity)
        {
            var properties = this.SqlProperties.Where(p => !this.KeySqlProperties.Any(k => k.PropertyName.Equals(p.PropertyName, StringComparison.OrdinalIgnoreCase)) && !p.IgnoreUpdate);

            if (this.HasUpdatedAt)
            {
                this.UpdatedAtProperty.SetValue(entity, DateTime.UtcNow);
            }

            var query = new SqlQuery(entity);
            query.SqlBuilder.Append("UPDATE " + this.TableName + " SET " + string.Join(", ", properties.Select(p => p.ColumnName + " = @" + p.PropertyName)) + " ");
            this.AppendWherePredicateQuery(query, predicate, QueryType.Update);

            return query;
        }

        /// <inheritdoc />
        public SqlQuery GetBulkUpdate(IEnumerable<TEntity> entities)
        {
            var entitiesArray = entities as TEntity[] ?? entities.ToArray();
            if (!entitiesArray.Any())
            {
                throw new ArgumentException("collection is empty");
            }

            var entityType = entitiesArray[0].GetType();

            var properties = this.SqlProperties.Where(p => !this.KeySqlProperties.Any(k => k.PropertyName.Equals(p.PropertyName, StringComparison.OrdinalIgnoreCase)) && !p.IgnoreUpdate).ToArray();

            var query = new SqlQuery();

            var parameters = new Dictionary<string, object>();

            for (var i = 0; i < entitiesArray.Length; i++)
            {
                if (this.HasUpdatedAt)
                {
                    this.UpdatedAtProperty.SetValue(entitiesArray[i], DateTime.UtcNow);
                }

                if (i > 0)
                {
                    query.SqlBuilder.Append("; ");
                }

                var i1 = i;
                var i2 = i;
                query.SqlBuilder.Append("UPDATE " + this.TableName + " SET " + string.Join(", ", properties.Select(p => p.ColumnName + " = @" + p.PropertyName + i1))
                    + " WHERE " + string.Join(" AND ", this.KeySqlProperties.Where(p => !p.IgnoreUpdate).Select(p => p.ColumnName + " = @" + p.PropertyName + i2)));

                foreach (var property in properties)
                {
                    parameters.Add(property.PropertyName + i, entityType.GetProperty(property.PropertyName)?.GetValue(entitiesArray[i], null));
                }

                foreach (var property in this.KeySqlProperties.Where(p => !p.IgnoreUpdate))
                {
                    parameters.Add(property.PropertyName + i, entityType.GetProperty(property.PropertyName)?.GetValue(entitiesArray[i], null));
                }
            }

            query.SetParam(parameters);

            return query;
        }

        /// <summary>
        /// The get fields select.
        /// </summary>
        /// <param name="tableName">
        /// The table name.
        /// </param>
        /// <param name="properties">
        /// The properties.
        /// </param>
        /// <returns>
        /// The <see cref="string"/>.
        /// </returns>
        private static string GetFieldsSelect(string tableName, IEnumerable<SqlPropertyMetadata> properties)
        {
            // Projection function
            string ProjectionFunction(SqlPropertyMetadata p) =>
                !string.IsNullOrEmpty(p.Alias)
                    ? tableName + "." + p.ColumnName + " AS " + p.PropertyName
                    : tableName + "." + p.ColumnName;

            return string.Join(", ", properties.Select(ProjectionFunction));
        }

        /// <summary>
        /// Get join/nested properties
        /// </summary>
        /// <param name="joinPropertiesInfo">
        /// The join Properties Info.
        /// </param>
        /// <returns>
        /// The <see cref="SqlJoinPropertyMetadata"/>.
        /// </returns>
        private static SqlJoinPropertyMetadata[] GetJoinPropertyMetadata(PropertyInfo[] joinPropertiesInfo)
        {
            // Filter and get only non collection nested properties
            var singleJoinTypes = joinPropertiesInfo.Where(p => !p.PropertyType.IsConstructedGenericType).ToArray();
            var propertiesList = new List<SqlJoinPropertyMetadata>();

            foreach (var propertyInfo in singleJoinTypes)
            {
                var joinInnerProperties = propertyInfo.PropertyType.GetProperties().Where(q => q.CanWrite).Where(ExpressionHelper.GetPrimitivePropertiesPredicate()).ToArray();

                propertiesList.AddRange(joinInnerProperties.Where(p => !p.GetCustomAttributes<NotMappedAttribute>().Any()).Select(p => new SqlJoinPropertyMetadata(propertyInfo, p)).ToArray());
            }

            return propertiesList.ToArray();
        }

        /// <summary>
        /// The get table name with schema prefix.
        /// </summary>
        /// <param name="tableName">
        /// The table name.
        /// </param>
        /// <param name="tableSchema">
        /// The table schema.
        /// </param>
        /// <param name="startQuotationMark">
        /// The start quotation mark.
        /// </param>
        /// <param name="endQuotationMark">
        /// The end quotation mark.
        /// </param>
        /// <returns>
        /// The <see cref="string"/>.
        /// </returns>
        private static string GetTableNameWithSchemaPrefix(string tableName, string tableSchema, string startQuotationMark = "", string endQuotationMark = "") =>
            !string.IsNullOrEmpty(tableSchema)
                ? startQuotationMark + tableSchema + endQuotationMark + "." + startQuotationMark + tableName + endQuotationMark
                : startQuotationMark + tableName + endQuotationMark;

        /// <summary>
        /// The append where predicate query.
        /// </summary>
        /// <param name="sqlQuery">
        /// The sql query.
        /// </param>
        /// <param name="predicate">
        /// The predicate.
        /// </param>
        /// <param name="queryType">
        /// The query type.
        /// </param>
        private void AppendWherePredicateQuery(SqlQuery sqlQuery, Expression<Func<TEntity, bool>> predicate, QueryType queryType)
        {
            IDictionary<string, object> dictionaryParams = new Dictionary<string, object>();

            if (predicate != null)
            {
                // WHERE
                var queryProperties = new List<QueryParameter>();
                this.FillQueryProperties(predicate.Body, ExpressionType.Default, ref queryProperties);

                sqlQuery.SqlBuilder.Append("WHERE ");

                for (var i = 0; i < queryProperties.Count; i++)
                {
                    var item = queryProperties[i];
                    var tableName = this.TableName;
                    string columnName;
                    if (item.NestedProperty)
                    {
                        var joinProperty = this.SqlJoinProperties.First(x => x.PropertyName == item.PropertyName);
                        tableName = joinProperty.TableAlias;
                        columnName = joinProperty.ColumnName;
                    }
                    else
                    {
                        columnName = this.SqlProperties.First(x => x.PropertyName == item.PropertyName).ColumnName;
                    }

                    if (!string.IsNullOrEmpty(item.LinkingOperator) && i > 0)
                    {
                        sqlQuery.SqlBuilder.Append(item.LinkingOperator + " ");
                    }

                    if (item.PropertyValue == null)
                    {
                        sqlQuery.SqlBuilder.Append(tableName + "." + columnName + " " + (item.QueryOperator == "=" ? "IS" : "IS NOT") + " NULL ");
                    }
                    else
                    {
                        sqlQuery.SqlBuilder.Append(tableName + "." + columnName + " " + item.QueryOperator + " @" + item.PropertyName + " ");
                    }

                    dictionaryParams[item.PropertyName] = item.PropertyValue;
                }

                if (this.LogicalDelete && queryType == QueryType.Select)
                {
                    sqlQuery.SqlBuilder.Append("AND " + this.TableName + "." + this.StatusPropertyName + " != " + this.LogicalDeleteValue + " ");
                }
            }
            else
            {
                if (this.LogicalDelete && queryType == QueryType.Select)
                {
                    sqlQuery.SqlBuilder.Append("WHERE " + this.TableName + "." + this.StatusPropertyName + " != " + this.LogicalDeleteValue + " ");
                }
            }

            if (this.LogicalDelete && this.HasUpdatedAt && queryType == QueryType.Delete)
            {
                dictionaryParams.Add(this.UpdatedAtPropertyMetadata.ColumnName, DateTime.UtcNow);
            }

            sqlQuery.SetParam(dictionaryParams);
        }

        /// <summary>
        /// The init properties.
        /// </summary>
        private void InitProperties()
        {
            var entityType = typeof(TEntity);
            var entityTypeInfo = entityType.GetTypeInfo();
            var tableAttribute = entityTypeInfo.GetCustomAttribute<TableAttribute>();

            this.TableName = tableAttribute != null ? tableAttribute.Name : entityTypeInfo.Name;
            this.TableSchema = tableAttribute != null ? tableAttribute.Schema : string.Empty;

            this.AllProperties = entityType.FindClassProperties().Where(q => q.CanWrite).ToArray();

            var props = this.AllProperties.Where(ExpressionHelper.GetPrimitivePropertiesPredicate()).ToArray();

            var joinProperties = this.AllProperties.Where(p => p.GetCustomAttributes<JoinAttributeBase>().Any()).ToArray();

            this.SqlJoinProperties = GetJoinPropertyMetadata(joinProperties);

            // Filter the non stored properties
            this.SqlProperties = props.Where(p => !p.GetCustomAttributes<NotMappedAttribute>().Any()).Select(p => new SqlPropertyMetadata(p)).ToArray();

            // Filter key properties
            this.KeySqlProperties = props.Where(p => p.GetCustomAttributes<KeyAttribute>().Any()).Select(p => new SqlPropertyMetadata(p)).ToArray();

            // Use identity as key pattern
            var identityProperty = props.FirstOrDefault(p => p.GetCustomAttributes<IdentityAttribute>().Any());
            this.IdentitySqlProperty = identityProperty != null ? new SqlPropertyMetadata(identityProperty) : null;

            var dateChangedProperty = props.FirstOrDefault(p => p.GetCustomAttributes<UpdatedAtAttribute>().Count() == 1);
            if (dateChangedProperty == null ||
                (dateChangedProperty.PropertyType != typeof(DateTime) &&
                 dateChangedProperty.PropertyType != typeof(DateTime?)))
            {
                return;
            }

            this.UpdatedAtProperty = props.FirstOrDefault(p => p.GetCustomAttributes<UpdatedAtAttribute>().Any());
            this.UpdatedAtPropertyMetadata = new SqlPropertyMetadata(this.UpdatedAtProperty);
        }

        /// <summary>
        /// Init type Sql provider
        /// </summary>
        /// <param name="sqlGeneratorConfig">
        /// The sql Generator Config.
        /// </param>
        private void InitConfig(SqlGeneratorConfig sqlGeneratorConfig)
        {
            this.Config = sqlGeneratorConfig;

            if (this.Config.UseQuotationMarks)
            {
                switch (this.Config.SqlProvider)
                {
                    case SqlProvider.MSSQL:
                        this.TableName = GetTableNameWithSchemaPrefix(this.TableName, this.TableSchema, "[", "]");

                        foreach (var propertyMetadata in this.SqlProperties)
                        {
                            propertyMetadata.ColumnName = "[" + propertyMetadata.ColumnName + "]";
                        }

                        foreach (var propertyMetadata in this.KeySqlProperties)
                        {
                            propertyMetadata.ColumnName = "[" + propertyMetadata.ColumnName + "]";
                        }

                        foreach (var propertyMetadata in this.SqlJoinProperties)
                        {
                            propertyMetadata.TableName = GetTableNameWithSchemaPrefix(propertyMetadata.TableName, propertyMetadata.TableSchema, "[", "]");
                            propertyMetadata.ColumnName = "[" + propertyMetadata.ColumnName + "]";
                            propertyMetadata.TableAlias = "[" + propertyMetadata.TableAlias + "]";
                        }

                        if (this.IdentitySqlProperty != null)
                        {
                            this.IdentitySqlProperty.ColumnName = "[" + this.IdentitySqlProperty.ColumnName + "]";
                        }

                        break;

                    case SqlProvider.MySQL:
                        this.TableName = GetTableNameWithSchemaPrefix(this.TableName, this.TableSchema, "`", "`");

                        foreach (var propertyMetadata in this.SqlProperties)
                        {
                            propertyMetadata.ColumnName = "`" + propertyMetadata.ColumnName + "`";
                        }

                        foreach (var propertyMetadata in this.KeySqlProperties)
                        {
                            propertyMetadata.ColumnName = "`" + propertyMetadata.ColumnName + "`";
                        }

                        foreach (var propertyMetadata in this.SqlJoinProperties)
                        {
                            propertyMetadata.TableName = GetTableNameWithSchemaPrefix(propertyMetadata.TableName, propertyMetadata.TableSchema, "`", "`");
                            propertyMetadata.ColumnName = "`" + propertyMetadata.ColumnName + "`";
                            propertyMetadata.TableAlias = "`" + propertyMetadata.TableAlias + "`";
                        }

                        if (this.IdentitySqlProperty != null)
                        {
                            this.IdentitySqlProperty.ColumnName = "`" + this.IdentitySqlProperty.ColumnName + "`";
                        }

                        break;

                    case SqlProvider.PostgreSQL:
                        this.TableName = GetTableNameWithSchemaPrefix(this.TableName, this.TableSchema, "\"", "\"");

                        foreach (var propertyMetadata in this.SqlProperties)
                        {
                            propertyMetadata.ColumnName = "\"" + propertyMetadata.ColumnName + "\"";
                        }

                        foreach (var propertyMetadata in this.KeySqlProperties)
                        {
                            propertyMetadata.ColumnName = "\"" + propertyMetadata.ColumnName + "\"";
                        }

                        foreach (var propertyMetadata in this.SqlJoinProperties)
                        {
                            propertyMetadata.TableName = GetTableNameWithSchemaPrefix(propertyMetadata.TableName, propertyMetadata.TableSchema, "\"", "\"");
                            propertyMetadata.ColumnName = "\"" + propertyMetadata.ColumnName + "\"";
                            propertyMetadata.TableAlias = "\"" + propertyMetadata.TableAlias + "\"";
                        }

                        if (this.IdentitySqlProperty != null)
                        {
                            this.IdentitySqlProperty.ColumnName = "\"" + this.IdentitySqlProperty.ColumnName + "\"";
                        }

                        break;

                    default:
                        throw new ArgumentOutOfRangeException(nameof(this.Config.SqlProvider));
                }
            }
            else
            {
                this.TableName = GetTableNameWithSchemaPrefix(this.TableName, this.TableSchema);
                foreach (var propertyMetadata in this.SqlJoinProperties)
                {
                    propertyMetadata.TableName = GetTableNameWithSchemaPrefix(propertyMetadata.TableName, propertyMetadata.TableSchema);
                }
            }
        }

        /// <summary>
        /// The init logical deleted.
        /// </summary>
        private void InitLogicalDeleted()
        {
            var statusProperty = this.SqlProperties.FirstOrDefault(x => x.PropertyInfo.GetCustomAttributes<StatusAttribute>().Any());

            if (statusProperty == null)
            {
                return;
            }

            this.StatusPropertyName = statusProperty.ColumnName;

            if (statusProperty.PropertyInfo.PropertyType.IsBool())
            {
                var deleteProperty = this.AllProperties.FirstOrDefault(p => p.GetCustomAttributes<DeletedAttribute>().Any());
                if (deleteProperty == null)
                {
                    return;
                }

                this.LogicalDelete = true;
                this.LogicalDeleteValue = 1; // true
            }
            else if (statusProperty.PropertyInfo.PropertyType.IsEnum())
            {
                var deleteOption = statusProperty.PropertyInfo.PropertyType.GetFields().FirstOrDefault(f => f.GetCustomAttribute<DeletedAttribute>() != null);

                if (deleteOption == null)
                {
                    return;
                }

                var enumValue = Enum.Parse(statusProperty.PropertyInfo.PropertyType, deleteOption.Name);
                this.LogicalDeleteValue = Convert.ChangeType(enumValue, Enum.GetUnderlyingType(statusProperty.PropertyInfo.PropertyType));

                this.LogicalDelete = true;
            }
        }

        /// <summary>
        /// The init builder select.
        /// </summary>
        /// <param name="firstOnly">
        /// The first only.
        /// </param>
        /// <returns>
        /// The <see cref="SqlQuery"/>.
        /// </returns>
        private SqlQuery InitBuilderSelect(bool firstOnly)
        {
            var query = new SqlQuery();
            query.SqlBuilder.Append("SELECT " + (firstOnly && this.Config.SqlProvider == SqlProvider.MSSQL ? "TOP 1 " : string.Empty) + GetFieldsSelect(this.TableName, this.SqlProperties));
            return query;
        }

        /// <summary>
        /// The init builder count with distinct.
        /// </summary>
        /// <param name="sqlProperty">
        /// The sql property.
        /// </param>
        /// <returns>
        /// The <see cref="SqlQuery"/>.
        /// </returns>
        private SqlQuery InitBuilderCountWithDistinct(SqlPropertyMetadata sqlProperty)
        {
            var query = new SqlQuery();
            var partSqlCountQuery = !string.IsNullOrEmpty(sqlProperty.Alias)
                ? this.TableName + "." + sqlProperty.ColumnName + ") AS " + sqlProperty.PropertyName
                : this.TableName + "." + sqlProperty.ColumnName + ")";
            query.SqlBuilder.Append("SELECT COUNT(DISTINCT " + partSqlCountQuery);
            return query;
        }

        /// <summary>
        /// The append join to select.
        /// </summary>
        /// <param name="originalBuilder">
        /// The original builder.
        /// </param>
        /// <param name="includes">
        /// The includes.
        /// </param>
        /// <returns>
        /// The <see cref="string"/>.
        /// </returns>
        private string AppendJoinToSelect(SqlQuery originalBuilder, params Expression<Func<TEntity, object>>[] includes)
        {
            var joinBuilder = new StringBuilder();

            foreach (var include in includes)
            {
                var joinProperty = this.AllProperties.First(q => q.Name == ExpressionHelper.GetPropertyName(include));
                if (joinProperty == null || joinProperty.DeclaringType == null)
                {
                    continue;
                }

                var declaringType = joinProperty.DeclaringType.GetTypeInfo();
                var tableAttribute = declaringType.GetCustomAttribute<TableAttribute>();
                var tableName = tableAttribute != null ? tableAttribute.Name : declaringType.Name;

                var attrJoin = joinProperty.GetCustomAttribute<JoinAttributeBase>();
                if (attrJoin == null)
                {
                    continue;
                }

                var joinString = attrJoin switch
                {
                    LeftJoinAttribute _ => "LEFT JOIN",
                    InnerJoinAttribute _ => "INNER JOIN",
                    RightJoinAttribute _ => "RIGHT JOIN",
                    _ => string.Empty
                };

                var joinType = joinProperty.PropertyType.IsGenericType() ? joinProperty.PropertyType.GenericTypeArguments[0] : joinProperty.PropertyType;
                var properties = joinType.FindClassProperties().Where(ExpressionHelper.GetPrimitivePropertiesPredicate());
                var props = properties.Where(p => !p.GetCustomAttributes<NotMappedAttribute>().Any()).Select(p => new SqlPropertyMetadata(p)).ToArray();

                if (this.Config.UseQuotationMarks)
                {
                    switch (this.Config.SqlProvider)
                    {
                        case SqlProvider.MSSQL:
                            tableName = "[" + tableName + "]";
                            attrJoin.TableName = GetTableNameWithSchemaPrefix(attrJoin.TableName, attrJoin.TableSchema, "[", "]");
                            attrJoin.Key = "[" + attrJoin.Key + "]";
                            attrJoin.ExternalKey = "[" + attrJoin.ExternalKey + "]";
                            attrJoin.TableAlias = "[" + attrJoin.TableAlias + "]";
                            foreach (var prop in props)
                            {
                                prop.ColumnName = "[" + prop.ColumnName + "]";
                            }

                            break;

                        case SqlProvider.MySQL:
                            tableName = "`" + tableName + "`";
                            attrJoin.TableName = GetTableNameWithSchemaPrefix(attrJoin.TableName, attrJoin.TableSchema, "`", "`");
                            attrJoin.Key = "`" + attrJoin.Key + "`";
                            attrJoin.ExternalKey = "`" + attrJoin.ExternalKey + "`";
                            attrJoin.TableAlias = "`" + attrJoin.TableAlias + "`";
                            foreach (var prop in props)
                            {
                                prop.ColumnName = "`" + prop.ColumnName + "`";
                            }

                            break;

                        case SqlProvider.PostgreSQL:
                            tableName = "\"" + tableName + "\"";
                            attrJoin.TableName = GetTableNameWithSchemaPrefix(attrJoin.TableName, attrJoin.TableSchema, "\"", "\"");
                            attrJoin.Key = "\"" + attrJoin.Key + "\"";
                            attrJoin.ExternalKey = "\"" + attrJoin.ExternalKey + "\"";
                            attrJoin.TableAlias = "\"" + attrJoin.TableAlias + "\"";
                            foreach (var prop in props)
                            {
                                prop.ColumnName = "\"" + prop.ColumnName + "\"";
                            }

                            break;

                        default:
                            throw new ArgumentOutOfRangeException(nameof(this.Config.SqlProvider));
                    }
                }
                else
                {
                    attrJoin.TableName = GetTableNameWithSchemaPrefix(attrJoin.TableName, attrJoin.TableSchema);
                }

                originalBuilder.SqlBuilder.Append($", {GetFieldsSelect(attrJoin.TableAlias, props)}");
                joinBuilder.Append($"{joinString} {attrJoin.TableName} AS {attrJoin.TableAlias} ON {tableName}.{attrJoin.Key} = {attrJoin.TableAlias}.{attrJoin.ExternalKey} ");
            }

            return joinBuilder.ToString();
        }

        /// <summary>
        /// The get select.
        /// </summary>
        /// <param name="predicate">
        /// The predicate.
        /// </param>
        /// <param name="firstOnly">
        /// The first only.
        /// </param>
        /// <param name="includes">
        /// The includes.
        /// </param>
        /// <returns>
        /// The <see cref="SqlQuery"/>.
        /// </returns>
        private SqlQuery GetSelect(Expression<Func<TEntity, bool>> predicate, bool firstOnly, params Expression<Func<TEntity, object>>[] includes)
        {
            var sqlQuery = this.InitBuilderSelect(firstOnly);

            if (includes.Any())
            {
                var joinsBuilder = this.AppendJoinToSelect(sqlQuery, includes);
                sqlQuery.SqlBuilder.Append(" FROM " + this.TableName + " ");
                sqlQuery.SqlBuilder.Append(joinsBuilder);
            }
            else
            {
                sqlQuery.SqlBuilder.Append(" FROM " + this.TableName + " ");
            }

            this.AppendWherePredicateQuery(sqlQuery, predicate, QueryType.Select);

            if (firstOnly && (this.Config.SqlProvider == SqlProvider.MySQL
                              || this.Config.SqlProvider == SqlProvider.PostgreSQL))
            {
                sqlQuery.SqlBuilder.Append("LIMIT 1");
            }

            return sqlQuery;
        }

        /// <summary>
        ///     Fill query properties
        /// </summary>
        /// <param name="expr">The expression.</param>
        /// <param name="linkingType">Type of the linking.</param>
        /// <param name="queryProperties">The query properties.</param>
        private void FillQueryProperties(Expression expr, ExpressionType linkingType, ref List<QueryParameter> queryProperties)
        {
            while (true)
            {
                if (expr is MethodCallExpression body)
                {
                    var innerBody = body;
                    var methodName = innerBody.Method.Name;
                    switch (methodName)
                    {
                        case "Contains":
                            {
                                var propertyName = ExpressionHelper.GetPropertyNamePath(innerBody, out var isNested);

                                if (!this.SqlProperties.Select(x => x.PropertyName).Contains(propertyName) && !this.SqlJoinProperties.Select(x => x.PropertyName).Contains(propertyName))
                                {
                                    throw new NotSupportedException("predicate can't parse");
                                }

                                var propertyValue = ExpressionHelper.GetValuesFromCollection(innerBody);
                                var opr = ExpressionHelper.GetMethodCallSqlOperator(methodName);
                                var link = ExpressionHelper.GetSqlOperator(linkingType);
                                queryProperties.Add(new QueryParameter(link, propertyName, propertyValue, opr, isNested));
                                break;
                            }

                        default:
                            throw new NotSupportedException($"'{methodName}' method is not supported");
                    }
                }
                else if (expr is BinaryExpression innerBody)
                {
                    if (innerBody.NodeType != ExpressionType.AndAlso && innerBody.NodeType != ExpressionType.OrElse)
                    {
                        var propertyName = ExpressionHelper.GetPropertyNamePath(innerBody, out var isNested);

                        if (!this.SqlProperties.Select(x => x.PropertyName).Contains(propertyName) && !this.SqlJoinProperties.Select(x => x.PropertyName).Contains(propertyName))
                        {
                            throw new NotSupportedException("predicate can't parse");
                        }

                        var propertyValue = ExpressionHelper.GetValue(innerBody.Right);
                        var opr = ExpressionHelper.GetSqlOperator(innerBody.NodeType);
                        var link = ExpressionHelper.GetSqlOperator(linkingType);

                        queryProperties.Add(new QueryParameter(link, propertyName, propertyValue, opr, isNested));
                    }
                    else
                    {
                        this.FillQueryProperties(innerBody.Left, innerBody.NodeType, ref queryProperties);
                        expr = innerBody.Right;
                        linkingType = innerBody.NodeType;
                        continue;
                    }
                }
                else
                {
                    expr = ExpressionHelper.GetBinaryExpression(expr);
                    continue;
                }

                break;
            }
        }
    }
}