/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 androidx.room.writer

import androidx.room.compiler.codegen.CodeLanguage
import androidx.room.compiler.codegen.VisibilityModifier
import androidx.room.compiler.codegen.XCodeBlock
import androidx.room.compiler.codegen.XCodeBlock.Builder.Companion.addLocalVal
import androidx.room.compiler.codegen.XFunSpec
import androidx.room.compiler.codegen.XPropertySpec
import androidx.room.compiler.codegen.XPropertySpec.Companion.apply
import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.XTypeSpec
import androidx.room.compiler.codegen.XTypeSpec.Builder.Companion.addOriginatingElement
import androidx.room.ext.AndroidTypeNames
import androidx.room.ext.CommonTypeNames
import androidx.room.ext.KotlinTypeNames
import androidx.room.ext.RoomTypeNames
import androidx.room.ext.SupportDbTypeNames
import androidx.room.ext.decapitalize
import androidx.room.ext.stripNonJava
import androidx.room.solver.CodeGenScope
import androidx.room.vo.DaoMethod
import androidx.room.vo.Database
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import java.util.Locale
import javax.lang.model.element.Modifier

/**
 * Writes implementation of classes that were annotated with @Database.
 */
class DatabaseWriter(
    val database: Database,
    codeLanguage: CodeLanguage
) : TypeWriter(codeLanguage) {
    override fun createTypeSpecBuilder(): XTypeSpec.Builder {
        return XTypeSpec.classBuilder(codeLanguage, database.implTypeName).apply {
            addOriginatingElement(database.element)
            superclass(database.typeName)
            setVisibility(VisibilityModifier.PUBLIC)
            addFunction(createCreateOpenHelper())
            addFunction(createCreateInvalidationTracker())
            addFunction(createClearAllTables())
            addFunction(createCreateTypeConvertersMap())
            addFunction(createCreateAutoMigrationSpecsSet())
            addFunction(getAutoMigrations())
            addDaoImpls(this)
        }
    }

    private fun createCreateTypeConvertersMap(): XFunSpec {
        val scope = CodeGenScope(this)
        val classOfAnyTypeName = CommonTypeNames.JAVA_CLASS.parametrizedBy(
            XTypeName.getProducerExtendsName(KotlinTypeNames.ANY)
        )
        val typeConvertersTypeName = CommonTypeNames.HASH_MAP.parametrizedBy(
            classOfAnyTypeName,
            CommonTypeNames.LIST.parametrizedBy(classOfAnyTypeName)
        )
        val body = XCodeBlock.builder(codeLanguage).apply {
            val typeConvertersVar = scope.getTmpVar("_typeConvertersMap")
            addLocalVariable(
                name = typeConvertersVar,
                typeName = typeConvertersTypeName,
                assignExpr = XCodeBlock.ofNewInstance(codeLanguage, typeConvertersTypeName)
            )
            database.daoMethods.forEach {
                addStatement(
                    "%L.put(%L, %T.%L())",
                    typeConvertersVar,
                    XCodeBlock.ofJavaClassLiteral(codeLanguage, it.dao.typeName),
                    it.dao.implTypeName,
                    DaoWriter.GET_LIST_OF_TYPE_CONVERTERS_METHOD
                )
            }
            addStatement("return %L", typeConvertersVar)
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "getRequiredTypeConverters",
            visibility = VisibilityModifier.PROTECTED,
            isOverride = true
        ).apply {
            returns(
                CommonTypeNames.MAP.parametrizedBy(
                    classOfAnyTypeName,
                    CommonTypeNames.LIST.parametrizedBy(classOfAnyTypeName)
                )
            )
            addCode(body)
        }.build()
    }

    private fun createCreateAutoMigrationSpecsSet(): XFunSpec {
        val scope = CodeGenScope(this)
        val classOfAutoMigrationSpecTypeName = CommonTypeNames.JAVA_CLASS.parametrizedBy(
            XTypeName.getProducerExtendsName(RoomTypeNames.AUTO_MIGRATION_SPEC)
        )
        val autoMigrationSpecsTypeName =
            CommonTypeNames.HASH_SET.parametrizedBy(classOfAutoMigrationSpecTypeName)
        val body = XCodeBlock.builder(codeLanguage).apply {
            val autoMigrationSpecsVar = scope.getTmpVar("_autoMigrationSpecsSet")
            addLocalVariable(
                name = autoMigrationSpecsVar,
                typeName = autoMigrationSpecsTypeName,
                assignExpr = XCodeBlock.ofNewInstance(codeLanguage, autoMigrationSpecsTypeName)
            )
            database.autoMigrations.filter { it.isSpecProvided }.map { autoMigration ->
                val specClassName = checkNotNull(autoMigration.specClassName)
                addStatement(
                    "%L.add(%L)",
                    autoMigrationSpecsVar,
                    XCodeBlock.ofJavaClassLiteral(codeLanguage, specClassName)
                )
            }
            addStatement("return %L", autoMigrationSpecsVar)
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "getRequiredAutoMigrationSpecs",
            visibility = VisibilityModifier.PUBLIC,
            isOverride = true,
        ).apply {
            returns(CommonTypeNames.SET.parametrizedBy(classOfAutoMigrationSpecTypeName))
            addCode(body)
        }.build()
    }

    private fun createClearAllTables(): XFunSpec {
        val scope = CodeGenScope(this)
        val body = XCodeBlock.builder(codeLanguage).apply {
            addStatement("super.assertNotMainThread()")
            val dbVar = scope.getTmpVar("_db")
            addLocalVal(
                dbVar,
                SupportDbTypeNames.DB,
                when (language) {
                    CodeLanguage.JAVA -> "super.getOpenHelper().getWritableDatabase()"
                    CodeLanguage.KOTLIN -> "super.openHelper.writableDatabase"
                }
            )
            val deferVar = scope.getTmpVar("_supportsDeferForeignKeys")
            if (database.enableForeignKeys) {
                addLocalVal(
                    deferVar,
                    XTypeName.PRIMITIVE_BOOLEAN,
                    "%L.VERSION.SDK_INT >= %L.VERSION_CODES.LOLLIPOP",
                    AndroidTypeNames.BUILD,
                    AndroidTypeNames.BUILD
                )
            }
            beginControlFlow("try").apply {
                if (database.enableForeignKeys) {
                    beginControlFlow("if (!%L)", deferVar).apply {
                        addStatement("%L.execSQL(%S)", dbVar, "PRAGMA foreign_keys = FALSE")
                    }
                    endControlFlow()
                }
                addStatement("super.beginTransaction()")
                if (database.enableForeignKeys) {
                    beginControlFlow("if (%L)", deferVar).apply {
                        addStatement("%L.execSQL(%S)", dbVar, "PRAGMA defer_foreign_keys = TRUE")
                    }
                    endControlFlow()
                }
                database.entities.sortedWith(EntityDeleteComparator()).forEach {
                    addStatement("%L.execSQL(%S)", dbVar, "DELETE FROM `${it.tableName}`")
                }
                addStatement("super.setTransactionSuccessful()")
            }
            nextControlFlow("finally").apply {
                addStatement("super.endTransaction()")
                if (database.enableForeignKeys) {
                    beginControlFlow("if (!%L)", deferVar).apply {
                        addStatement("%L.execSQL(%S)", dbVar, "PRAGMA foreign_keys = TRUE")
                    }
                    endControlFlow()
                }
                addStatement("%L.query(%S).close()", dbVar, "PRAGMA wal_checkpoint(FULL)")
                beginControlFlow("if (!%L.inTransaction())", dbVar).apply {
                    addStatement("%L.execSQL(%S)", dbVar, "VACUUM")
                }
                endControlFlow()
            }
            endControlFlow()
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "clearAllTables",
            visibility = VisibilityModifier.PUBLIC,
            isOverride = true
        ).apply {
            addCode(body)
        }.build()
    }

    private fun createCreateInvalidationTracker(): XFunSpec {
        val scope = CodeGenScope(this)
        val body = XCodeBlock.builder(codeLanguage).apply {
            val shadowTablesVar = "_shadowTablesMap"
            val shadowTablesTypeName = CommonTypeNames.HASH_MAP.parametrizedBy(
                CommonTypeNames.STRING, CommonTypeNames.STRING
            )
            val tableNames = database.entities.joinToString(",") {
                "\"${it.tableName}\""
            }
            val shadowTableNames = database.entities.filter {
                it.shadowTableName != null
            }.map {
                it.tableName to it.shadowTableName
            }
            addLocalVariable(
                name = shadowTablesVar,
                typeName = shadowTablesTypeName,
                assignExpr = XCodeBlock.ofNewInstance(
                    codeLanguage,
                    shadowTablesTypeName,
                    "%L",
                    shadowTableNames.size
                )
            )
            shadowTableNames.forEach { (tableName, shadowTableName) ->
                addStatement("%L.put(%S, %S)", shadowTablesVar, tableName, shadowTableName)
            }
            val viewTablesVar = scope.getTmpVar("_viewTables")
            val tablesType = CommonTypeNames.HASH_SET.parametrizedBy(CommonTypeNames.STRING)
            val viewTablesType = CommonTypeNames.HASH_MAP.parametrizedBy(
                CommonTypeNames.STRING,
                CommonTypeNames.SET.parametrizedBy(CommonTypeNames.STRING)
            )
            addLocalVariable(
                name = viewTablesVar,
                typeName = viewTablesType,
                assignExpr = XCodeBlock.ofNewInstance(
                    codeLanguage,
                    viewTablesType,
                    "%L", database.views.size
                )
            )
            for (view in database.views) {
                val tablesVar = scope.getTmpVar("_tables")
                addLocalVariable(
                    name = tablesVar,
                    typeName = tablesType,
                    assignExpr = XCodeBlock.ofNewInstance(
                        codeLanguage,
                        tablesType,
                        "%L", view.tables.size
                    )
                )
                for (table in view.tables) {
                    addStatement("%L.add(%S)", tablesVar, table)
                }
                addStatement(
                    "%L.put(%S, %L)",
                    viewTablesVar, view.viewName.lowercase(Locale.US), tablesVar
                )
            }
            addStatement(
                "return %L",
                XCodeBlock.ofNewInstance(
                    codeLanguage,
                    RoomTypeNames.INVALIDATION_TRACKER,
                    "this, %L, %L, %L",
                    shadowTablesVar, viewTablesVar, tableNames
                )
            )
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "createInvalidationTracker",
            visibility = VisibilityModifier.PROTECTED,
            isOverride = true
        ).apply {
            returns(RoomTypeNames.INVALIDATION_TRACKER)
            addCode(body)
        }.build()
    }

    private fun addDaoImpls(builder: XTypeSpec.Builder) {
        val scope = CodeGenScope(this)
        database.daoMethods.forEach { method ->
            val name = method.dao.typeName.simpleNames.first()
                .decapitalize(Locale.US)
                .stripNonJava()
            val privateDaoProperty = XPropertySpec.builder(
                language = codeLanguage,
                name = scope.getTmpVar("_$name"),
                typeName = if (codeLanguage == CodeLanguage.KOTLIN) {
                    KotlinTypeNames.LAZY.parametrizedBy(method.dao.typeName)
                } else {
                    method.dao.typeName
                },
                visibility = VisibilityModifier.PRIVATE,
                isMutable = codeLanguage == CodeLanguage.JAVA
            ).apply {
                // For Kotlin we rely on kotlin.Lazy while for Java we'll memoize the dao impl in
                // the getter.
                if (language == CodeLanguage.KOTLIN) {
                   initializer(
                       XCodeBlock.of(
                           language,
                           "lazy { %L }",
                           XCodeBlock.ofNewInstance(language, method.dao.implTypeName, "this")
                       )
                   )
                }
            }.apply(
                javaFieldBuilder = {
                    // The volatile modifier is needed since in Java the memoization is generated.
                    addModifiers(Modifier.VOLATILE)
                },
                kotlinPropertyBuilder = { }
            ).build()
            builder.addProperty(privateDaoProperty)
            val overrideProperty =
                codeLanguage == CodeLanguage.KOTLIN && method.element.isKotlinPropertyMethod()
            if (overrideProperty) {
                builder.addProperty(createDaoProperty(method, privateDaoProperty))
            } else {
                builder.addFunction(createDaoGetter(method, privateDaoProperty))
            }
        }
    }

    private fun createDaoGetter(method: DaoMethod, daoProperty: XPropertySpec): XFunSpec {
        val body = XCodeBlock.builder(codeLanguage).apply {
            // For Java we implement the memoization logic in the Dao getter, meanwhile for Kotlin
            // we rely on kotlin.Lazy to the getter just delegates to it.
            when (codeLanguage) {
                CodeLanguage.JAVA -> {
                    beginControlFlow("if (%N != null)", daoProperty).apply {
                        addStatement("return %N", daoProperty)
                    }
                    nextControlFlow("else").apply {
                        beginControlFlow("synchronized(this)").apply {
                            beginControlFlow("if(%N == null)", daoProperty).apply {
                                addStatement(
                                    "%N = %L",
                                    daoProperty,
                                    XCodeBlock.ofNewInstance(
                                        language,
                                        method.dao.implTypeName,
                                        "this"
                                    )

                                )
                            }
                            endControlFlow()
                            addStatement("return %N", daoProperty)
                        }
                        endControlFlow()
                    }
                    endControlFlow()
                }
                CodeLanguage.KOTLIN -> {
                    addStatement("return %N.value", daoProperty)
                }
            }
        }
        return XFunSpec.overridingBuilder(
            language = codeLanguage,
            element = method.element,
            owner = database.element.type
        ).apply {
            addCode(body.build())
        }.build()
    }

    private fun createDaoProperty(method: DaoMethod, daoProperty: XPropertySpec): XPropertySpec {
        // TODO(b/257967987): This has a few flaws that need to be fixed.
        return XPropertySpec.builder(
            language = codeLanguage,
            name = method.element.name.drop(3).replaceFirstChar { it.lowercase(Locale.US) },
            typeName = method.dao.typeName,
            visibility = when {
                method.element.isPublic() -> VisibilityModifier.PUBLIC
                method.element.isProtected() -> VisibilityModifier.PROTECTED
                else -> VisibilityModifier.PUBLIC // Might be internal... ?
            }
        ).apply(
            javaFieldBuilder = { error("Overriding a property in Java is impossible!") },
            kotlinPropertyBuilder = {
                addModifiers(KModifier.OVERRIDE)
                getter(
                    FunSpec.getterBuilder()
                        .addStatement("return %L.value", daoProperty.name)
                        .build()
                )
            }
        ).build()
    }

    private fun createCreateOpenHelper(): XFunSpec {
        val scope = CodeGenScope(this)
        val configParamName = "config"
        val body = XCodeBlock.builder(codeLanguage).apply {
            val openHelperVar = scope.getTmpVar("_helper")
            val openHelperCode = scope.fork()
            SQLiteOpenHelperWriter(database)
                .write(openHelperVar, configParamName, openHelperCode)
            add(openHelperCode.generate())
            addStatement("return %L", openHelperVar)
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "createOpenHelper",
            visibility = VisibilityModifier.PROTECTED,
            isOverride = true,
        ).apply {
            returns(SupportDbTypeNames.SQLITE_OPEN_HELPER)
            addParameter(RoomTypeNames.ROOM_DB_CONFIG, configParamName)
            addCode(body)
        }.build()
    }

    private fun getAutoMigrations(): XFunSpec {
        val scope = CodeGenScope(this)
        val classOfAutoMigrationSpecTypeName = CommonTypeNames.JAVA_CLASS.parametrizedBy(
            XTypeName.getProducerExtendsName(RoomTypeNames.AUTO_MIGRATION_SPEC)
        )
        val autoMigrationsListTypeName =
            CommonTypeNames.ARRAY_LIST.parametrizedBy(RoomTypeNames.MIGRATION)
        val specsMapParamName = "autoMigrationSpecs"
        val body = XCodeBlock.builder(codeLanguage).apply {
            val listVar = scope.getTmpVar("_autoMigrations")
            addLocalVariable(
                name = listVar,
                typeName = CommonTypeNames.LIST.parametrizedBy(RoomTypeNames.MIGRATION),
                assignExpr = XCodeBlock.ofNewInstance(codeLanguage, autoMigrationsListTypeName)
            )
            database.autoMigrations.forEach { autoMigrationResult ->
                val implTypeName =
                    autoMigrationResult.getImplTypeName(database.typeName)
                val newInstanceCode = if (autoMigrationResult.isSpecProvided) {
                    val specClassName = checkNotNull(autoMigrationResult.specClassName)
                    // For Kotlin use getValue() as the Map's values are never null.
                    val getFunction = when (language) {
                        CodeLanguage.JAVA -> "get"
                        CodeLanguage.KOTLIN -> "getValue"
                    }
                    XCodeBlock.ofNewInstance(
                        language,
                        implTypeName,
                        "%L.%L(%L)",
                        specsMapParamName,
                        getFunction,
                        XCodeBlock.ofJavaClassLiteral(language, specClassName)
                    )
                } else {
                    XCodeBlock.ofNewInstance(language, implTypeName)
                }
                addStatement("%L.add(%L)", listVar, newInstanceCode)
            }
            addStatement("return %L", listVar)
        }.build()
        return XFunSpec.builder(
            language = codeLanguage,
            name = "getAutoMigrations",
            visibility = VisibilityModifier.PUBLIC,
            isOverride = true,
        ).apply {
            returns(CommonTypeNames.LIST.parametrizedBy(RoomTypeNames.MIGRATION))
            addParameter(
                CommonTypeNames.MAP.parametrizedBy(
                    classOfAutoMigrationSpecTypeName,
                    RoomTypeNames.AUTO_MIGRATION_SPEC,
                ),
                specsMapParamName
            )
            addCode(body)
        }.build()
    }
}
