/*
 * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
 *
 * Licensed 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 net.sergeych.lyng.io.db.sqlite

import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlColumnMeta
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlExecutionResultData
import net.sergeych.lyng.io.db.SqlResultSetData
import net.sergeych.lyng.io.db.SqlTransactionBackend
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDate
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.sqlite.SQLiteConfig
import org.sqlite.SQLiteErrorCode
import org.sqlite.SQLiteOpenMode
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
import java.sql.Statement
import kotlin.time.Instant

internal actual suspend fun openSqliteBackend(
    scope: ScopeFacade,
    core: SqlCoreModule,
    options: SqliteOpenOptions,
): SqlDatabaseBackend {
    if (options.busyTimeoutMillis < 0) {
        scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
    }
    return JdbcSqlDatabaseBackend(core, options)
}

private class JdbcSqlDatabaseBackend(
    private val core: SqlCoreModule,
    private val options: SqliteOpenOptions,
) : SqlDatabaseBackend {
    override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
        val connection = openConnection(scope)
        try {
            connection.autoCommit = false
            val tx = JdbcSqlTransactionBackend(core, connection)
            val result = try {
                block(tx)
            } catch (e: Throwable) {
                throw finishFailedTransaction(scope, core, e) {
                    rollbackOrThrow(scope, core, connection)
                }
            }
            try {
                connection.commit()
            } catch (e: SQLException) {
                throw mapSqlException(scope, core, e)
            }
            return result
        } catch (e: SQLException) {
            throw mapSqlException(scope, core, e)
        } finally {
            try {
                connection.close()
            } catch (_: SQLException) {
            }
        }
    }

    private fun openConnection(scope: ScopeFacade): Connection {
        try {
            val config = SQLiteConfig().apply {
                setOpenMode(SQLiteOpenMode.OPEN_URI)
                if (options.readOnly) {
                    setReadOnly(true)
                    setOpenMode(SQLiteOpenMode.READONLY)
                } else {
                    setReadOnly(false)
                    setOpenMode(SQLiteOpenMode.READWRITE)
                    if (options.createIfMissing) {
                        setOpenMode(SQLiteOpenMode.CREATE)
                    }
                }
                enforceForeignKeys(options.foreignKeys)
                busyTimeout = options.busyTimeoutMillis
            }
            return DriverManager.getConnection(jdbcUrl(options.path), config.toProperties())
        } catch (e: SQLException) {
            throw mapOpenException(scope, core, e)
        } catch (e: IllegalArgumentException) {
            scope.raiseIllegalArgument(e.message ?: "Invalid SQLite configuration")
        }
    }

    private fun jdbcUrl(path: String): String {
        return when (path) {
            ":memory:" -> "jdbc:sqlite::memory:"
            else -> if (path.startsWith("/")) "jdbc:sqlite:$path" else "jdbc:sqlite:$path"
        }
    }
}

private class JdbcSqlTransactionBackend(
    private val core: SqlCoreModule,
    private val connection: Connection,
) : SqlTransactionBackend {
    override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
        try {
            connection.prepareStatement(clause).use { statement ->
                bindParams(statement, params, scope, core)
                statement.executeQuery().use { rs ->
                    return readResultSet(scope, core, rs)
                }
            }
        } catch (e: SQLException) {
            throw mapSqlException(scope, core, e)
        }
    }

    override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
        if (containsRowReturningClause(clause)) {
            scope.raiseError(
                ObjException(
                    core.sqlUsageException,
                    scope.requireScope(),
                    ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
                )
            )
        }
        try {
            connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
                bindParams(statement, params, scope, core)
                val hasResultSet = statement.execute()
                if (hasResultSet) {
                    scope.raiseError(
                        ObjException(
                            core.sqlUsageException,
                            scope.requireScope(),
                            ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
                        )
                    )
                }
                val affected = statement.updateCount
                val generatedKeys = statement.generatedKeys.use { rs ->
                    if (rs == null) {
                        emptyResultSet(core)
                    } else {
                        readResultSet(scope, core, rs)
                    }
                }
                return SqlExecutionResultData(affected, generatedKeys)
            }
        } catch (e: SQLException) {
            throw mapSqlException(scope, core, e)
        }
    }

    override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
        val savepoint = try {
            connection.setSavepoint()
        } catch (e: SQLException) {
            throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
        }
        val nested = JdbcSqlTransactionBackend(core, connection)
        val result = try {
            block(nested)
        } catch (e: Throwable) {
            throw finishFailedTransaction(scope, core, e) {
                rollbackToSavepointOrThrow(scope, core, connection, savepoint)
                releaseSavepointOrThrow(scope, core, connection, savepoint)
            }
        }
        try {
            connection.releaseSavepoint(savepoint)
        } catch (e: SQLException) {
            throw mapSqlException(scope, core, e)
        }
        return result
    }
}

private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqlCoreModule) {
    params.forEachIndexed { index, value ->
        val jdbcIndex = index + 1
        when (value) {
            ObjNull -> statement.setObject(jdbcIndex, null)
            is ObjBool -> statement.setLong(jdbcIndex, if (value.value) 1L else 0L)
            is ObjInt -> statement.setLong(jdbcIndex, value.value)
            is ObjReal -> statement.setDouble(jdbcIndex, value.value)
            is ObjString -> statement.setString(jdbcIndex, value.value)
            is ObjBuffer -> statement.setBytes(jdbcIndex, value.byteArray.toByteArray())
            is ObjInstant -> statement.setString(jdbcIndex, value.instant.toString())
            is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString())
            else -> when (value.objClass.className) {
                "Date", "Decimal" -> statement.setString(jdbcIndex, scope.toStringOf(value).value)
                else -> scope.raiseError(
                    ObjException(
                        core.sqlUsageException,
                        scope.requireScope(),
                        ObjString("Unsupported SQLite parameter type: ${value.objClass.className}")
                    )
                )
            }
        }
    }
}

private suspend fun readResultSet(
    scope: ScopeFacade,
    core: SqlCoreModule,
    resultSet: ResultSet,
): SqlResultSetData {
    val meta = resultSet.metaData
    val columns = (1..meta.columnCount).map { index ->
        SqlColumnMeta(
            name = meta.getColumnLabel(index),
            sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
            nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
            nativeType = meta.getColumnTypeName(index) ?: "",
        )
    }
    val rows = mutableListOf<List<Obj>>()
    while (resultSet.next()) {
        rows += columns.mapIndexed { index, column ->
            readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
        }
    }
    return SqlResultSetData(columns, rows)
}

private suspend fun readColumnValue(
    scope: ScopeFacade,
    core: SqlCoreModule,
    resultSet: ResultSet,
    index: Int,
    nativeType: String,
): Obj {
    val value = resultSet.getObject(index) ?: return ObjNull
    val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
    if (isDecimalNativeType(normalizedNativeType) && value is Number) {
        return decimalFromString(scope, value.toString())
    }
    return when (value) {
        is Boolean -> if (isBooleanNativeType(normalizedNativeType)) ObjBool(value) else ObjInt.of(if (value) 1 else 0)
        is Byte, is Short, is Int -> convertIntegerValue(scope, core, normalizedNativeType, (value as Number).toLong())
        is Long -> convertIntegerValue(scope, core, normalizedNativeType, value)
        is Float, is Double -> ObjReal.of((value as Number).toDouble())
        is ByteArray -> ObjBuffer(value.toUByteArray())
        is String -> convertStringValue(scope, core, normalizedNativeType, value)
        is java.math.BigDecimal -> decimalFromString(scope, value.toPlainString())
        else -> ObjString(value.toString())
    }
}

private fun convertIntegerValue(
    scope: ScopeFacade,
    core: SqlCoreModule,
    normalizedNativeType: String,
    value: Long,
): Obj {
    if (!isBooleanNativeType(normalizedNativeType)) {
        return ObjInt.of(value)
    }
    return when (value) {
        0L -> ObjBool(false)
        1L -> ObjBool(true)
        else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
    }
}

private suspend fun convertStringValue(
    scope: ScopeFacade,
    core: SqlCoreModule,
    normalizedNativeType: String,
    value: String,
): Obj {
    return when {
        isBooleanNativeType(normalizedNativeType) -> booleanFromString(scope, core, value)
        isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim())
        normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
        normalizedNativeType == "DATETIME" || normalizedNativeType == "TIMESTAMP" ->
            dateTimeFromString(scope, core, value)
        normalizedNativeType == "TIMESTAMP WITH TIME ZONE" ||
            normalizedNativeType == "TIMESTAMPTZ" ||
            normalizedNativeType == "DATETIME WITH TIME ZONE" -> ObjInstant(Instant.parse(value.trim()))
        else -> ObjString(value)
    }
}

private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
    normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC"

private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
    normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"

private fun booleanFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): Obj {
    return when (value.trim().lowercase()) {
        "true", "t" -> ObjBool(true)
        "false", "f" -> ObjBool(false)
        else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
    }
}

private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
    val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
    val decimalClass = decimalModule.requireClass("Decimal")
    return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
}

private fun dateTimeFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): ObjDateTime {
    val trimmed = value.trim()
    if (hasExplicitTimeZone(trimmed)) {
        sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
    }
    val local = LocalDateTime.parse(trimmed)
    return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
}

private fun hasExplicitTimeZone(value: String): Boolean {
    if (value.endsWith("Z", ignoreCase = true)) return true
    val tIndex = value.indexOf('T')
    if (tIndex < 0) return false
    val plus = value.lastIndexOf('+')
    val minus = value.lastIndexOf('-')
    val offsetStart = maxOf(plus, minus)
    return offsetStart > tIndex
}

private fun containsRowReturningClause(clause: String): Boolean =
    Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)

private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
    val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
    return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
}

private fun mapSqlType(core: SqlCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
    val normalized = normalizeDeclaredTypeName(nativeTypeName)
    return when {
        normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
        normalized == "DATE" -> core.sqlTypes.require("Date")
        normalized == "DATETIME" || normalized == "TIMESTAMP" -> core.sqlTypes.require("DateTime")
        normalized == "TIMESTAMP WITH TIME ZONE" ||
            normalized == "TIMESTAMPTZ" ||
            normalized == "DATETIME WITH TIME ZONE" -> core.sqlTypes.require("Instant")
        normalized == "TIME" ||
            normalized == "TIME WITHOUT TIME ZONE" ||
            normalized == "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
        normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
        normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
        normalized.contains("INT") -> core.sqlTypes.require("Int")
        normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String")
        normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
        jdbcType == java.sql.Types.BOOLEAN -> core.sqlTypes.require("Bool")
        jdbcType == java.sql.Types.BLOB || jdbcType == java.sql.Types.BINARY || jdbcType == java.sql.Types.VARBINARY -> core.sqlTypes.require("Binary")
        jdbcType == java.sql.Types.INTEGER -> core.sqlTypes.require("Int")
        jdbcType == java.sql.Types.BIGINT -> core.sqlTypes.require("Int")
        jdbcType == java.sql.Types.DECIMAL || jdbcType == java.sql.Types.NUMERIC -> core.sqlTypes.require("Decimal")
        jdbcType == java.sql.Types.FLOAT || jdbcType == java.sql.Types.REAL || jdbcType == java.sql.Types.DOUBLE -> core.sqlTypes.require("Double")
        else -> core.sqlTypes.require("String")
    }
}

private fun emptyResultSet(core: SqlCoreModule): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())

private fun sqlExecutionFailure(scope: ScopeFacade, core: SqlCoreModule, message: String): Nothing {
    throw ExecutionError(
        ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
        scope.pos,
        message,
    )
}

private fun rollbackOrThrow(scope: ScopeFacade, core: SqlCoreModule, connection: Connection) {
    try {
        connection.rollback()
    } catch (e: SQLException) {
        throw mapSqlException(scope, core, e)
    }
}

private fun rollbackToSavepointOrThrow(
    scope: ScopeFacade,
    core: SqlCoreModule,
    connection: Connection,
    savepoint: java.sql.Savepoint,
) {
    try {
        connection.rollback(savepoint)
    } catch (e: SQLException) {
        throw mapSqlException(scope, core, e)
    }
}

private fun releaseSavepointOrThrow(
    scope: ScopeFacade,
    core: SqlCoreModule,
    connection: Connection,
    savepoint: java.sql.Savepoint,
) {
    try {
        connection.releaseSavepoint(savepoint)
    } catch (e: SQLException) {
        throw mapSqlException(scope, core, e)
    }
}

private inline fun finishFailedTransaction(
    scope: ScopeFacade,
    core: SqlCoreModule,
    failure: Throwable,
    rollback: () -> Unit,
): Throwable {
    return try {
        rollback()
        failure
    } catch (rollbackFailure: Throwable) {
        if (isRollbackSignal(failure, core)) {
            attachSecondaryFailure(rollbackFailure, failure)
            rollbackFailure
        } else {
            attachSecondaryFailure(failure, rollbackFailure)
            failure
        }
    }
}

private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
    val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
    return errorObject.isInstanceOf(core.rollbackException)
}

private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
    if (primary === secondary) return
    primary.addSuppressed(secondary)
}

private fun mapOpenException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): Nothing {
    val message = e.message ?: "SQLite open failed"
    val lower = message.lowercase()
    if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
        scope.raiseIllegalArgument(message)
    }
    throw ExecutionError(
        ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
        scope.pos,
        message,
        e,
    )
}

private fun mapSqlException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): ExecutionError {
    val code = SQLiteErrorCode.getErrorCode(e.errorCode)
    val exceptionClass = when (code) {
        SQLiteErrorCode.SQLITE_CONSTRAINT,
        SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY,
        SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE,
        SQLiteErrorCode.SQLITE_CONSTRAINT_FOREIGNKEY,
        SQLiteErrorCode.SQLITE_CONSTRAINT_NOTNULL -> core.sqlConstraintException
        else -> core.sqlExecutionException
    }
    return ExecutionError(
        ObjException(exceptionClass, scope.requireScope(), ObjString(e.message ?: "SQLite error")),
        scope.pos,
        e.message ?: "SQLite error",
        e,
    )
}

private fun mapSqlUsage(scope: ScopeFacade, core: SqlCoreModule, message: String, cause: Throwable? = null): ExecutionError {
    return ExecutionError(
        ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
        scope.pos,
        message,
        cause,
    )
}
