/*
 * 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.
 */

namespace Apache.Ignite.Tests.Linq;

using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Ignite.Table;
using NodaTime;
using NUnit.Framework;
using Table;

/// <summary>
/// Linq functions tests.
/// </summary>
[SuppressMessage("Globalization", "CA1304:Specify CultureInfo", Justification = "SQL")]
[SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringIndexOfIsCultureSpecific.1", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringIndexOfIsCultureSpecific.2", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringCompareToIsCultureSpecific", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringCompareIsCultureSpecific.1", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringCompareIsCultureSpecific.2", Justification = "SQL")]
[SuppressMessage("ReSharper", "StringCompareIsCultureSpecific.3", Justification = "SQL")]
[SuppressMessage("Globalization", "CA1310:Specify StringComparison for correctness", Justification = "SQL")]
public partial class LinqTests
{
    [Test]
    public void TestNumericOperators()
    {
        TestOpDouble(x => x.Key + 1.234d, 10.234d, "select (_T0.KEY + ?) from");
        TestOpDouble(x => x.Key - 1.234d, 7.766d, "select (_T0.KEY - ?) from");
        TestOpDouble(x => x.Key * 2.5d, 22.5d, "select (_T0.KEY * ?) from");
        TestOpDouble(x => x.Key / 3d, 3.0d, "select (_T0.KEY / ?) from");

        TestOpInt(x => x.Key + 1.234d, 10.234d, "select (cast(_T0.KEY as double) + ?) from");
        TestOpInt(x => x.Key - 1.234d, 7.766d, "select (cast(_T0.KEY as double) - ?) from");
        TestOpInt(x => x.Key * 2.5d, 22.5d, "select (cast(_T0.KEY as double) * ?) from");
        TestOpInt(x => x.Key / 3d, 3.0d, "select (cast(_T0.KEY as double) / ?) from");
    }

    [Test]
    public void TestRemainder()
    {
        TestOpDouble(x => x.Key % 3d, 2.0d, "select (_T0.KEY % ?) from");
        TestOpInt(x => x.Key % 4, 3, "select (_T0.KEY % ?) from");
    }

    [Test]
    public void TestNumericFunctions()
    {
        // ACOSH, ASINH, ATANH, ATAN2, LOG2, LOG(X, Y) are not supported by Calcite.
        TestOpDouble(x => Math.Abs(-x.Key), 9.0d, "select Abs((-_T0.KEY)) from");
        TestOpDouble(x => Math.Cos(x.Key + 2), 0.96017028665036597d, "select Cos((_T0.KEY + ?)) from");
        TestOpDouble(x => Math.Cosh(x.Key), 4051.5420254925943d, "select Cosh(_T0.KEY) from");
        TestOpDouble(x => Math.Acos(x.Key / 100), 1.5707963267948966d, "select Acos((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Sin(x.Key), 0.98935824662338179d, "select Sin(_T0.KEY) from");
        TestOpDouble(x => Math.Sinh(x.Key), 4051.5419020827899d, "select Sinh(_T0.KEY) from");
        TestOpDouble(x => Math.Asin(x.Key / 100), 0.090121945014595251d, "select Asin((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Tan(x.Key), 1.5574077246549023d, "select Tan(_T0.KEY) from");
        TestOpDouble(x => Math.Tanh(x.Key / 10), 0.71629787019902447d, "select Tanh((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Atan(x.Key), 1.4601391056210009d, "select Atan(_T0.KEY) from");
        TestOpDouble(x => Math.Ceiling(x.Key / 3), 3.0d, "select Ceiling((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Floor(x.Key / 3), 3.0d, "select Floor((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Exp(x.Key), 8103.0839275753842d, "select Exp(_T0.KEY) from");
        TestOpDouble(x => Math.Log(x.Key), 2.1972245773362196d, "select Ln(_T0.KEY) from");
        TestOpDouble(x => Math.Log10(x.Key), 0.95424250943932487d, "select Log10(_T0.KEY) from");
        TestOpDouble(x => Math.Pow(x.Key, 2), 81, "select Power(_T0.KEY, ?) from");
        TestOpDouble(x => Math.Round(x.Key / 5), 2, "select Round((_T0.KEY / ?)) from");
        TestOpDouble(x => Math.Sign(x.Key - 10), -1, "select Sign((_T0.KEY - ?)) from");
        TestOpDouble(x => Math.Sqrt(x.Key), 3.0d, "select Sqrt(_T0.KEY) from");
        TestOpDouble(x => Math.Truncate(x.Key + 0.8), 9.0d, "select Truncate((_T0.KEY + ?)) from");
    }

    [Test]
    [SuppressMessage("Globalization", "CA1311:Specify a culture or use an invariant version", Justification = "SQL")]
    public void TestStringFunctions()
    {
        TestOpString(x => x.Val + "_", "v-9_", "select concat(_T0.VAL, ?) from");
        TestOpString(x => "_" + x.Val, "_v-9", "select concat(?, _T0.VAL) from");
        TestOpString(x => "[" + x.Val + "]", "[v-9]", "select concat(concat(?, _T0.VAL), ?) from");

        TestOpString(x => x.Val!.ToUpper(), "V-9", "select upper(_T0.VAL) from");
        TestOpString(x => x.Val!.ToLower(), "v-9", "select lower(_T0.VAL) from");

        TestOpString(x => x.Val!.Substring(1), "-9", "select substring(_T0.VAL, ? + 1) from");
        TestOpString(x => x.Val!.Substring(0, 2), "v-", "select substring(_T0.VAL, ? + 1, ?) from");

        TestOpString(x => x.Val!.Trim(), "v-9", "select trim(_T0.VAL) from");
        TestOpString(x => x.Val!.TrimStart(), "v-9", "select ltrim(_T0.VAL) from");
        TestOpString(x => x.Val!.TrimEnd(), "v-9", "select rtrim(_T0.VAL) from");

        TestOpString(x => x.Val!.Trim('v'), "-9", "select trim(both ? from _T0.VAL) from");
        TestOpString(x => x.Val!.TrimStart('v'), "-9", "select trim(leading ? from _T0.VAL) from");
        TestOpString(x => x.Val!.TrimEnd('9'), "v-8", "select trim(trailing ? from _T0.VAL) from");

        TestOpString(x => x.Val!.Contains("v-"), true, "select (_T0.VAL like '%' || ? || '%') from");
        TestOpString(x => x.Val!.StartsWith("v-"), true, "select (_T0.VAL like ? || '%') from");
        TestOpString(x => x.Val!.EndsWith("-9"), true, "select (_T0.VAL like '%' || ?) from");

        TestOpString(x => x.Val!.IndexOf("-9"), 1, "select -1 + position(? in _T0.VAL) from");
        TestOpString(x => x.Val!.IndexOf("-9", 2), -1, "select -1 + position(? in _T0.VAL from (? + 1)) from");

        TestOpString(x => x.Val!.Length, 3, "select length(_T0.VAL) from");

        TestOpString(x => x.Val!.Replace("v-", "x + "), "x + 9", "select replace(_T0.VAL, ?, ?) from");
    }

    [Test]
    public void TestStringCompare()
    {
        var expectedQuery = "select case when (_T0.VAL is not distinct from ?) then 0 " +
                            "else (case when (_T0.VAL > ?) then 1 else -1 end) end from";

        TestOpString(x => string.Compare(x.Val, "abc"), 1, expectedQuery);

        var expectedQueryIgnoreCase = "select case when (lower(_T0.VAL) is not distinct from lower(?)) then 0 " +
                                      "else (case when (lower(_T0.VAL) > lower(?)) then 1 else -1 end) end from";

        TestOpString(x => string.Compare(x.Val, "abc", true), 1, expectedQueryIgnoreCase);
    }

    [Test]
    public void TestStringCompareValues()
    {
        Assert.AreEqual(0, Test("v-5"));
        Assert.AreEqual(1, Test("v-4"));
        Assert.AreEqual(-1, Test("v-6"));
        Assert.AreEqual(1, Test("a-5"));
        Assert.AreEqual(-1, Test("x-5"));

        Assert.AreEqual(0, TestIgnoreCase("V-5", true));
        Assert.AreEqual(1, TestIgnoreCase("V-4", true));
        Assert.AreEqual(-1, TestIgnoreCase("V-6", true));
        Assert.AreEqual(1, TestIgnoreCase("A-5", true));
        Assert.AreEqual(-1, TestIgnoreCase("X-5", true));

        int Test(string val)
        {
            var res = PocoView.AsQueryable()
                .Where(x => x.Val == "v-5")
                .Select(x => string.Compare(x.Val, val))
                .Single();

            var clrRes = string.Compare("v-5", val, CultureInfo.InvariantCulture, CompareOptions.None);
            Assert.AreEqual(clrRes, res, "CLR result is different");
            TestIgnoreCase(val, false);

            return res;
        }

        int TestIgnoreCase(string val, bool ignoreCase)
        {
            var res = PocoView.AsQueryable()
                .Where(x => x.Val == "v-5")
                .Select(x => string.Compare(x.Val, val, ignoreCase))
                .Single();

            var clrRes = string.Compare("v-5", val, ignoreCase, CultureInfo.InvariantCulture);
            Assert.AreEqual(clrRes, res, "CLR result is different");

            return res;
        }
    }

    [Test]
    public void TestTernary()
    {
        TestOpInt(
            x => x.Key > 1000 ? 2 : 3,
            3,
            "select case when((_T0.KEY > ?)) then ? else ? end from");

        TestOpInt(
            x => x.Key > 1000 ? x.Val + 2 : x.Val + 3,
            903,
            "select case when((_T0.KEY > ?)) then (_T0.VAL + ?) else (_T0.VAL + ?) end from");
    }

    [Test]
    public async Task TestDateFunctions()
    {
        var dates = (await Client.Tables.GetTableAsync(TableDateName))!.GetRecordView<PocoDate>();
        var localDate = new LocalDate(2022, 12, 20);
        await dates.UpsertAsync(null, new PocoDate(localDate, localDate));

        TestOp(dates, x => x.Key.Year, 2022, "select year(_T0.KEY) from");
        TestOp(dates, x => x.Key.Month, 12, "select month(_T0.KEY) from");
        TestOp(dates, x => x.Key.Day, 20, "select dayofmonth(_T0.KEY) from");

        TestOp(dates, x => x.Key.DayOfYear, 354, "select dayofyear(_T0.KEY) from");
        TestOp(dates, x => x.Key.DayOfWeek, IsoDayOfWeek.Tuesday, "select -1 + dayofweek(_T0.KEY) from");
        TestOp(dates, x => (int)x.Key.DayOfWeek, (int)IsoDayOfWeek.Tuesday, "select cast(-1 + dayofweek(_T0.KEY) as int) from");
    }

    [Test]
    public async Task TestDateTimeFunctions()
    {
        var dateTimes = (await Client.Tables.GetTableAsync(TableDateTimeName))!.GetRecordView<PocoDateTime>();
        var localDateTime = new LocalDateTime(2022, 12, 20, 20, 07, 36, 123);
        await dateTimes.UpsertAsync(null, new PocoDateTime(localDateTime, localDateTime));

        TestOp(dateTimes, x => x.Key.Year, 2022, "select year(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.Month, 12, "select month(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.Day, 20, "select dayofmonth(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.DayOfYear, 354, "select dayofyear(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.DayOfWeek, IsoDayOfWeek.Tuesday, "select -1 + dayofweek(_T0.KEY) from");
        TestOp(dateTimes, x => (int)x.Key.DayOfWeek, (int)IsoDayOfWeek.Tuesday, "select cast(-1 + dayofweek(_T0.KEY) as int) from");
        TestOp(dateTimes, x => x.Key.Hour, 20, "select hour(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.Minute, 7, "select minute(_T0.KEY) from");
        TestOp(dateTimes, x => x.Key.Second, 36, "select second(_T0.KEY) from");

        var projection = dateTimes.AsQueryable().Select(x => new
        {
            x.Key.Year,
            x.Key.Month,
            x.Key.Day,
            x.Key.DayOfYear,
            x.Key.DayOfWeek,
            x.Key.Hour,
            x.Key.Minute,
            x.Key.Second
        }).First();

        Assert.AreEqual(2022, projection.Year);
        Assert.AreEqual(12, projection.Month);
        Assert.AreEqual(20, projection.Day);
        Assert.AreEqual(354, projection.DayOfYear);
        Assert.AreEqual(IsoDayOfWeek.Tuesday, projection.DayOfWeek);
        Assert.AreEqual(20, projection.Hour);
        Assert.AreEqual(7, projection.Minute);
        Assert.AreEqual(36, projection.Second);
    }

    [Test]
    public async Task TestTimeFunctions()
    {
        var times = (await Client.Tables.GetTableAsync(TableTimeName))!.GetRecordView<PocoTime>();
        var localTime = new LocalTime(20, 07, 36, 123);
        await times.UpsertAsync(null, new PocoTime(localTime, localTime));

        TestOp(times, x => x.Key.Hour, 20, "select hour(_T0.KEY) from");
        TestOp(times, x => x.Key.Minute, 7, "select minute(_T0.KEY) from");
        TestOp(times, x => x.Key.Second, 36, "select second(_T0.KEY) from");
    }

    [Test]
    public void TestRegexReplace()
    {
        TestOpString(
            x => Regex.Replace(x.Val!, @"([a-z])-(\d+)", "0_$2_$1"),
            "0_9_v",
            "select regexp_replace(_T0.VAL, ?, ?) from");

        TestOpString(
            x => Regex.Replace(x.Val!, @"V-(\d+)", "V$1", RegexOptions.IgnoreCase | RegexOptions.Multiline),
            "V9",
            @"select regexp_replace(_T0.VAL, ?, ?, 1, 1, ?) from PUBLIC.TBL1 as _T0, Parameters = [ V-(\d+), V$1, im ]");
    }

    private static void TestOp<T, TRes>(IRecordView<T> view, Expression<Func<T, TRes>> expr, TRes expectedRes, string expectedQuery)
        where T : notnull
    {
        var query = view.AsQueryable().Select(expr);
        var sql = query.ToString();

        TRes? res;

        try
        {
            res = query.Max();
        }
        catch (Exception e)
        {
            throw new Exception("Failed to execute query: " + sql, e);
        }

        Assert.Multiple(() =>
        {
            if (expectedRes is double expectedDoubleRes)
            {
                // Double-precision results can be a bit different depending on OS and hardware.
                Assert.AreEqual(expectedDoubleRes, (double)(object)res!, 1e-15);
            }
            else
            {
                Assert.AreEqual(expectedRes, res);
            }

            StringAssert.Contains(expectedQuery, sql);
        });
    }

    private void TestOpDouble<TRes>(Expression<Func<PocoDouble, TRes>> expr, TRes expectedRes, string expectedQuery) =>
        TestOp(PocoDoubleView, expr, expectedRes, expectedQuery);

    private void TestOpInt<TRes>(Expression<Func<PocoInt, TRes>> expr, TRes expectedRes, string expectedQuery) =>
        TestOp(PocoIntView, expr, expectedRes, expectedQuery);

    private void TestOpString<TRes>(Expression<Func<Poco, TRes>> expr, TRes expectedRes, string expectedQuery) =>
        TestOp(PocoView, expr, expectedRes, expectedQuery);
}
