#include <config.h>

#define BOOST_TEST_MODULE TestGpuPvt

#include <boost/test/unit_test.hpp>
#include <opm/material/common/MathToolbox.hpp>
#include <opm/material/densead/Evaluation.hpp>
#include <opm/material/densead/Math.hpp>
#include <opm/material/common/UniformTabulated2DFunction.hpp>
#include <opm/material/components/CO2Tables.hpp>
#include <opm/material/components/CO2.hpp>
#include <opm/material/components/SimpleHuDuanH2O.hpp>
#include <opm/material/components/BrineDynamic.hpp>
#include <opm/material/binarycoefficients/Brine_CO2.hpp>
#include <opm/material/fluidsystems/blackoilpvt/Co2GasPvt.hpp>
#include <opm/material/fluidsystems/blackoilpvt/BrineCo2Pvt.hpp>
#include <opm/input/eclipse/EclipseState/Co2StoreConfig.hpp>

#include <opm/simulators/linalg/gpuistl/detail/gpu_safe_call.hpp>
#include <opm/simulators/linalg/gpuistl/GpuBuffer.hpp>
#include <opm/simulators/linalg/gpuistl/GpuView.hpp>

#include <cuda_runtime.h>
#include <vector>
#include <utility>
#include <cmath>

using Evaluation = Opm::DenseAd::Evaluation<double, 3>;
using GpuB = Opm::gpuistl::GpuBuffer<double>;
using GpuV = Opm::gpuistl::GpuView<double>;

using GpuTab = Opm::UniformTabulated2DFunction<double, GpuV>;

using GpuBufCo2Tables = Opm::CO2Tables<double, GpuB>;
using GpuViewCO2Tables = Opm::CO2Tables<double, GpuV>;
using GpuCO2 = Opm::CO2<double, GpuViewCO2Tables>;

using HuDuan = Opm::SimpleHuDuanH2O<double>;
using BrineDyn = Opm::BrineDynamic<double, HuDuan>;

using CpuBrine_CO2 = Opm::BinaryCoeff::Brine_CO2<double, HuDuan, Opm::CO2<double>>;
using GpuBrine_CO2 = Opm::BinaryCoeff::Brine_CO2<double, HuDuan, GpuCO2>;

using CpuCo2Pvt = Opm::Co2GasPvt<double>;
using GpuBufCo2Pvt = Opm::Co2GasPvt<double, GpuBufCo2Tables, GpuB>;
using GpuViewCo2Pvt = Opm::Co2GasPvt<double, GpuViewCO2Tables, GpuV>;

using CpuBrineCo2Pvt = Opm::BrineCo2Pvt<double>;
using GpuBufBrineCo2Pvt = Opm::BrineCo2Pvt<double, GpuBufCo2Tables, GpuB>;
using GpuViewBrineCo2Pvt = Opm::BrineCo2Pvt<double, GpuViewCO2Tables, GpuV>;

namespace {

/*
    This file contains unit tests for Pvt objects and function on the GPU, with additional helper classes
*/

const double ABS_TOL = 1e-6;

struct Fixture {
    Fixture(){
        temp = Evaluation(290.5);
        pressure = Evaluation(200000.0);

        gpuComputedResultOnCpu = 0.0;

        // move pvt evaluations to gpu
        OPM_GPU_SAFE_CALL(cudaMalloc(&gpuTemp, sizeof(Evaluation)));
        OPM_GPU_SAFE_CALL(cudaMemcpy(gpuTemp, &temp, sizeof(Evaluation), cudaMemcpyHostToDevice));
        OPM_GPU_SAFE_CALL(cudaMalloc(&gpuPressure, sizeof(Evaluation)));
        OPM_GPU_SAFE_CALL(cudaMemcpy(gpuPressure, &pressure, sizeof(Evaluation), cudaMemcpyHostToDevice));
    }
    ~Fixture(){
        OPM_GPU_SAFE_CALL(cudaFree(gpuTemp));
        OPM_GPU_SAFE_CALL(cudaFree(gpuPressure));
    }

    Evaluation temp; // [K]
    Evaluation pressure; // [Pa]
    Evaluation* gpuTemp; // [K]
    Evaluation* gpuPressure; // [Pa]

    double gpuComputedResultOnCpu;

    Opm::CO2Tables<double, std::vector<double>> co2Tables;
};

// Kernel to evaluate a 2D function on the GPU
__global__ void gpuEvaluateUniformTabulated2DFunction(GpuTab gpuTab, Evaluation* inputX, Evaluation* inputY, double* result) {
    *result = gpuTab.eval(*inputX, *inputY, true).value();
}

// Kernel using a CO2 object on the GPU
__global__ void gpuCO2GasViscosity(GpuViewCO2Tables gpuViewCo2Tables, Evaluation* temp, Evaluation* pressure, double* result) {
    *result = GpuCO2::gasViscosity(gpuViewCo2Tables, *temp, *pressure, true).value();
}

// Kernel using a SimpleHuDuanH20 object on a GPU
__global__ void huDuanLiquidDensity(Evaluation* temp, Evaluation* pressure, double* result) {
    *result = HuDuan::liquidDensity(*temp, *pressure, true).value();
}

// Kernel using a BrineDynamic object on a GPU
__global__ void brineDynamicLiquidEnthalpy(Evaluation* temp, Evaluation* pressure, Evaluation* salinity, double* result) {
    *result = BrineDyn::liquidEnthalpy(*temp, *pressure, *salinity).value();
}

// Kernel using a Brine_CO2 object on a GPU
__global__ void brineCO2GasDiffCoeff(GpuViewCO2Tables co2tables, Evaluation* temp, Evaluation* pressure, double* result) {
    *result = GpuBrine_CO2::gasDiffCoeff<Evaluation, GpuViewCO2Tables>(co2tables, *temp, *pressure, true).value();
}

// Kernel using a Co2GasPvt object on a GPU
__global__ void co2GasPvtInternalEnergy(GpuViewCo2Pvt gpuViewCo2Pvt, Evaluation* temp, Evaluation* pressure, double* result) {
    *result = gpuViewCo2Pvt.internalEnergy(1, *temp, *pressure, Evaluation(0.4), Evaluation(0.0)).value();
}

// Kernel using a BrineCo2Pvt object on a GPU
__global__ void brineCo2PvtInternalEnergy(GpuViewBrineCo2Pvt gpuViewBrineCo2Pvt, Evaluation* temp, Evaluation* pressure, Evaluation* rs, Evaluation* saltConcentration, double* result) {
    *result = gpuViewBrineCo2Pvt.internalEnergy(1, *temp, *pressure, *rs, *saltConcentration).value();
}

// Helper function to launch a kernel and retrieve the result on the CPU to reduce code duplicatoin
template <typename KernelFunc, typename... Args>
double launchKernelAndRetrieveResult(KernelFunc kernel, Args... args) {
    double* resultOnGpu;
    double gpuComputedResultOnCpu;

    // Allocate memory for the result on the GPU
    OPM_GPU_SAFE_CALL(cudaMalloc(&resultOnGpu, sizeof(double)));

    // Launch the kernel
    kernel<<<1, 1>>>(args..., resultOnGpu);

    // Check for any errors in kernel launch
    OPM_GPU_SAFE_CALL(cudaPeekAtLastError());
    OPM_GPU_SAFE_CALL(cudaDeviceSynchronize());

    // Retrieve the result from the GPU to the CPU
    OPM_GPU_SAFE_CALL(cudaMemcpy(&gpuComputedResultOnCpu, resultOnGpu, sizeof(double), cudaMemcpyDeviceToHost));

    // Free allocated GPU memory
    OPM_GPU_SAFE_CALL(cudaFree(resultOnGpu));

    return gpuComputedResultOnCpu;
}

bool compareSignificantDigits(double a, double b, int significantDigits) {
    // Handle the case where both values are exactly equal
    if (a == b) {
        return true;
    }

    // Calculate the relative error
    double relativeError = std::abs(a - b) / std::max(std::abs(a), std::abs(b));

    // Compute the number of matching digits
    double digitsMatched = -std::log10(relativeError);

    // Return true if they match the required number of significant digits
    return digitsMatched >= significantDigits;
}

} // END EMPTY NAMESPACE

// Test case for evaluating a tabulated 2D function on both CPU and GPU
BOOST_FIXTURE_TEST_CASE(TestEvaluateUniformTabulated2DFunctionOnGpu, Fixture) {
    // Example tabulated data (2D)
    std::vector<std::vector<double>> tabData = {{1.0, 2.0}, {3.0, 4.0}, {5.0, 6.0}};

    // CPU-side function definition
    Opm::UniformTabulated2DFunction<double> cpuTab(1.0, 6.0, 3, 1.0, 6.0, 2, tabData);

    // Move data to GPU buffer and create a view for GPU operations
    Opm::UniformTabulated2DFunction<double, GpuB> gpuBufTab = Opm::gpuistl::copy_to_gpu<GpuB>(cpuTab);
    GpuTab gpuViewTab = Opm::gpuistl::make_view<GpuV>(gpuBufTab);

    // Evaluation points on the CPU
    Evaluation a(2.3);
    Evaluation b(4.5);

    // Allocate GPU memory for the Evaluation inputs
    Evaluation* gpuA = nullptr;
    Evaluation* gpuB = nullptr;
    OPM_GPU_SAFE_CALL(cudaMalloc(&gpuA, sizeof(Evaluation)));
    OPM_GPU_SAFE_CALL(cudaMemcpy(gpuA, &a, sizeof(Evaluation), cudaMemcpyHostToDevice));
    OPM_GPU_SAFE_CALL(cudaMalloc(&gpuB, sizeof(Evaluation)));
    OPM_GPU_SAFE_CALL(cudaMemcpy(gpuB, &b, sizeof(Evaluation), cudaMemcpyHostToDevice));

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(gpuEvaluateUniformTabulated2DFunction, gpuViewTab, gpuA, gpuB);

    // Free allocated GPU memory
    OPM_GPU_SAFE_CALL(cudaFree(gpuA));
    OPM_GPU_SAFE_CALL(cudaFree(gpuB));

    // Verify that the CPU and GPU results match within a reasonable tolerance
    const double cpuComputedResult = cpuTab.eval(a, b, true).value();
    BOOST_CHECK(std::fabs(gpuComputedResultOnCpu - cpuComputedResult) < ABS_TOL);
}

// Test case evaluating CO2 pvt properties on CPU and GPU
BOOST_FIXTURE_TEST_CASE(TestUseCO2OnGpu, Fixture) {

    // use the CO2 tables to aquire the viscosity at 290[K] and 2e5[Pa]
    double viscosityReference = Opm::CO2<double, Opm::CO2Tables<double, std::vector<double>>>::gasViscosity(co2Tables, temp, pressure, true).value();

    GpuBufCo2Tables gpuBufCo2Table = Opm::gpuistl::copy_to_gpu<GpuB>(co2Tables);
    GpuViewCO2Tables gpuViewCo2Table = Opm::gpuistl::make_view<GpuV>(gpuBufCo2Table);

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(gpuCO2GasViscosity, gpuViewCo2Table, gpuTemp, gpuPressure);

    // Verify that the CPU and GPU results match within a reasonable tolerance
    BOOST_CHECK(std::fabs(gpuComputedResultOnCpu - viscosityReference) < ABS_TOL);
}

// Test case evaluating pvt values for SimpleHuDuanH20 on a GPU and CPU
BOOST_FIXTURE_TEST_CASE(TestUseH2OOnGpu, Fixture) {

    // use the CO2 tables to aquire the densityReference at 290[K] and 2e5[Pa]
    double densityReference = HuDuan::liquidDensity(temp, pressure, true).value();

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(huDuanLiquidDensity, gpuTemp, gpuPressure);

    // Verify that the CPU and GPU results match within a reasonable tolerance
    BOOST_CHECK(std::fabs(gpuComputedResultOnCpu - densityReference) < ABS_TOL);
}

// Test case evaluating pvt values for BrineDynamic on a GPU and CPU
BOOST_FIXTURE_TEST_CASE(TestUseBrineDynamicOnGpu, Fixture) {
    Evaluation salinity(0.1); // [g/Kg]

    // use the CO2 tables to aquire the enthalpyReference at 290[K] and 2e5[Pa]
    double enthalpyReference = BrineDyn::liquidEnthalpy(temp, pressure, salinity).value();

    // Allocate GPU memory for the Evaluation inputs
    Evaluation* gpuSalinity = nullptr;
    OPM_GPU_SAFE_CALL(cudaMalloc(&gpuSalinity, sizeof(Evaluation)));
    OPM_GPU_SAFE_CALL(cudaMemcpy(gpuSalinity, &salinity, sizeof(Evaluation), cudaMemcpyHostToDevice));

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(brineDynamicLiquidEnthalpy, gpuTemp, gpuPressure, gpuSalinity);

    // Verify that the CPU and GPU results match within a reasonable tolerance
    BOOST_CHECK(std::fabs(gpuComputedResultOnCpu - enthalpyReference) < ABS_TOL);
}

// Test case evaluating pvt values for BrineDynamic on a GPU and CPU
BOOST_FIXTURE_TEST_CASE(TestBrine_CO2OnGPU, Fixture) {

    // use the CO2 tables to aquire the gasDiffCoeffReference at 290[K] and 2e5[Pa]
    double gasDiffCoeffReference = CpuBrine_CO2::gasDiffCoeff(co2Tables, temp, pressure, true).value();

    // use the CO2 tables to aquire the viscosity at 290[K] and 2e5[Pa]
    double viscosity = Opm::CO2<double, Opm::CO2Tables<double, std::vector<double>>>::gasViscosity(co2Tables, temp, pressure, true).value();

    GpuBufCo2Tables gpuBufCo2Table = Opm::gpuistl::copy_to_gpu<GpuB>(co2Tables);
    GpuViewCO2Tables gpuViewCo2Table = Opm::gpuistl::make_view<GpuV>(gpuBufCo2Table);

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(brineCO2GasDiffCoeff, gpuViewCo2Table, gpuTemp, gpuPressure);

    // Verify that the CPU and GPU results match within a reasonable tolerance
    BOOST_CHECK(std::fabs(gpuComputedResultOnCpu - gasDiffCoeffReference) < ABS_TOL);
}

// Test case evaluating pvt values for BrineDynamic on a GPU and CPU
BOOST_FIXTURE_TEST_CASE(TestCo2GasPvt, Fixture) {
    std::vector<double> salinities = {0.2, 0.3, 0.4};

    CpuCo2Pvt cpuCo2Pvt(salinities);
    double internalEnergyReference = cpuCo2Pvt.internalEnergy(1, temp, pressure, Evaluation(0.4), Evaluation(0.0)).value();

    GpuBufCo2Pvt gpuBufCo2Pvt = Opm::gpuistl::copy_to_gpu<GpuB, GpuBufCo2Tables>(cpuCo2Pvt);
    auto brineReferenceDensityCPUCopy = gpuBufCo2Pvt.getBrineReferenceDensity().asStdVector();
    GpuViewCo2Pvt gpuViewCo2Pvt = Opm::gpuistl::make_view<GpuV, GpuViewCO2Tables>(gpuBufCo2Pvt);

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(co2GasPvtInternalEnergy, gpuViewCo2Pvt, gpuTemp, gpuPressure);

    // Verify that the CPU and GPU results match within a reasonable tolerance
    BOOST_CHECK(compareSignificantDigits(gpuComputedResultOnCpu, internalEnergyReference, 6));
}


BOOST_FIXTURE_TEST_CASE(TestBrineCo2Pvt, Fixture) {
    Evaluation rs(0.3);
    Evaluation saltConcentration(0.1);
    std::vector<double> salinities = {0.2, 0.3, 0.4};

    CpuBrineCo2Pvt cpuBrineCo2Pvt(salinities);
    double internalEnergyReference = cpuBrineCo2Pvt.internalEnergy(1, temp, pressure, rs, saltConcentration).value();

    GpuBufBrineCo2Pvt gpuBufBrineCo2Pvt = Opm::gpuistl::copy_to_gpu<GpuBufCo2Tables, GpuB>(cpuBrineCo2Pvt);
    GpuViewBrineCo2Pvt gpuViewBrineCo2Pvt = Opm::gpuistl::make_view<GpuV, GpuViewCO2Tables>(gpuBufBrineCo2Pvt);

    // Allocate memory for the result on the GPU
    double* resultOnGpu = nullptr;
    OPM_GPU_SAFE_CALL(cudaMalloc(&resultOnGpu, sizeof(double)));

    // Allocate GPU memory for the Evaluation inputs
    Evaluation* gpuRs = nullptr;
    Evaluation* gpuSaltConcentration = nullptr;
    OPM_GPU_SAFE_CALL(cudaMalloc(&gpuRs, sizeof(Evaluation)));
    OPM_GPU_SAFE_CALL(cudaMemcpy(gpuRs, &rs, sizeof(Evaluation), cudaMemcpyHostToDevice));
    OPM_GPU_SAFE_CALL(cudaMalloc(&gpuSaltConcentration, sizeof(Evaluation)));
    OPM_GPU_SAFE_CALL(cudaMemcpy(gpuSaltConcentration, &saltConcentration, sizeof(Evaluation), cudaMemcpyHostToDevice));

    gpuComputedResultOnCpu = launchKernelAndRetrieveResult(brineCo2PvtInternalEnergy, gpuViewBrineCo2Pvt, gpuTemp, gpuPressure, gpuRs, gpuSaltConcentration);

    BOOST_CHECK(compareSignificantDigits(gpuComputedResultOnCpu, internalEnergyReference, 6));
}
