// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package com.flutter.gradle

import androidx.annotation.VisibleForTesting
import com.android.build.api.AndroidPluginVersion
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.Variant
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.logging.Logger
import org.gradle.kotlin.dsl.extra

/**
 * Warns or errors on version ranges of dependencies required to build a Flutter Android app.
 *
 * For code that evaluates if dependencies are compatible with each other see
 * packages/flutter_tools/lib/src/android/gradle_utils.dart.
 */
object DependencyVersionChecker {
    // Logging constants.
    @VisibleForTesting internal const val GRADLE_NAME: String = "Gradle"

    @VisibleForTesting internal const val JAVA_NAME: String = "Java"

    @VisibleForTesting internal const val AGP_NAME: String = "Android Gradle Plugin"

    @VisibleForTesting internal const val KGP_NAME: String = "Kotlin"

    @VisibleForTesting internal const val MIN_SDK_NAME: String = "minimum Android SDK"

    // String constant that defines the name of the Gradle extra property that we set when
    // detecting that the project is using versions outside of Flutter's support range.
    // https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api/-project/index.html#-2107180640%2FProperties%2F-1867656071.
    @VisibleForTesting internal const val OUT_OF_SUPPORT_RANGE_PROPERTY = "usesUnsupportedDependencyVersions"

    // The task prefix for assemble builds.
    @VisibleForTesting
    internal const val ASSEMBLE_PREFIX = "assemble"

    // The task postfix to use when checking the minimum SDK version for each flavor.
    internal const val MIN_SDK_CHECK_TASK_POSTFIX = "MinSdkCheck"

    // The following messages represent best effort guesses at where a Flutter developer should
    // look to upgrade a dependency that is below the corresponding threshold. Developers can
    // change some of these locations, so they are not guaranteed to be accurate.
    @VisibleForTesting internal fun getPotentialGradleFix(projectDirectory: String): String =
        "Your project's gradle version is typically " +
            "defined in the gradle wrapper file. By default, this can be found at " +
            "$projectDirectory/gradle/wrapper/gradle-wrapper.properties. \n" +
            "For more information, see https://docs.gradle.org/current/userguide/gradle_wrapper.html.\n"

    // The potential java fix does not make use of the project directory,
    // so it left as a constant.
    @VisibleForTesting internal const val POTENTIAL_JAVA_FIX: String =
        "The Java version used by Flutter can be " +
            "set with `flutter config --jdk-dir=<path>`. \nFor more information about how Flutter " +
            "chooses which version of Java to use, see the --jdk-dir section of the " +
            "output of `flutter config -h`.\n"

    @VisibleForTesting internal fun getPotentialAGPFix(projectDirectory: String): String =
        "Your project's AGP version is typically " +
            "defined in the plugins block of the `settings.gradle` file " +
            "($projectDirectory/settings.gradle), by a plugin with the id of " +
            "com.android.application. \nIf you don't see a plugins block, your project " +
            "was likely created with an older template version. In this case it is most " +
            "likely defined in the top-level build.gradle file " +
            "($projectDirectory/build.gradle) by the following line in the dependencies" +
            " block of the buildscript: \"classpath 'com.android.tools.build:gradle:<version>'\".\n"

    @VisibleForTesting internal fun getPotentialKGPFix(projectDirectory: String): String =
        "Your project's KGP version is typically " +
            "defined in the plugins block of the `settings.gradle` file " +
            "($projectDirectory/settings.gradle), by a plugin with the id of " +
            "org.jetbrains.kotlin.android. \nIf you don't see a plugins block, your project " +
            "was likely created with an older template version, in which case it is most " +
            "likely defined in the top-level build.gradle file " +
            "($projectDirectory/build.gradle) by the ext.kotlin_version property.\n"

    @VisibleForTesting internal fun getPotentialSDKFix(projectDirectory: String): String =
        "Your project's minimum Android SDK version is typically " +
            "defined in the android block of the app-level `build.gradle(.kts)` file " +
            "($projectDirectory/app/build.gradle(.kts))."

    // The following versions define our support policy for Gradle, Java, AGP, and KGP.
    // Before updating any "error" version, ensure that you have updated the corresponding
    // "warn" version for a full release to provide advanced warning. See
    // flutter.dev/go/android-dependency-versions for more.
    // Advice for maintainers for other areas of code that are impacted are documented
    // in packages/flutter_tools/lib/src/android/README.md.
    @VisibleForTesting internal val warnGradleVersion: Version = Version(8, 7, 0)

    @VisibleForTesting internal val errorGradleVersion: Version = Version(8, 3, 0)

    @VisibleForTesting internal val warnJavaVersion: JavaVersion = JavaVersion.VERSION_17

    @VisibleForTesting internal val errorJavaVersion: JavaVersion = JavaVersion.VERSION_11

    @VisibleForTesting internal val warnAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 6, 0)

    @VisibleForTesting internal val errorAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 1, 1)

    @VisibleForTesting internal val warnKGPVersion: Version = Version(2, 1, 0)

    @VisibleForTesting internal val errorKGPVersion: Version = Version(1, 8, 10)

    // If this value is changed, then make sure to change the documentation on https://docs.flutter.dev/reference/supported-platforms
    // Non inclusive.
    @VisibleForTesting
    internal val warnMinSdkVersion: Int = 24

    @VisibleForTesting
    internal val errorMinSdkVersion: Int = 23

    /**
     * Checks if the project's Android build time dependencies are each within the respective
     * version range that we support. When we can't find a version for a given dependency
     * we treat it as within the range for the purpose of this check.
     */
    @JvmStatic fun checkDependencyVersions(project: Project) {
        project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, false)

        checkGradleVersion(VersionFetcher.getGradleVersion(project), project)
        checkJavaVersion(VersionFetcher.getJavaVersion(), project)

        configureMinSdkCheck(project)

        val agpVersion: AndroidPluginVersion? = VersionFetcher.getAGPVersion(project)
        if (agpVersion != null) {
            checkAGPVersion(agpVersion, project)
        } else {
            project.logger.error(
                "Warning: unable to detect project AGP version. Skipping " +
                    "version checking. \nThis may be because you have applied AGP after the Flutter Gradle Plugin."
            )
        }

        val kgpVersion: Version? = VersionFetcher.getKGPVersion(project)
        if (kgpVersion != null) {
            checkKGPVersion(kgpVersion, project)
        }
        // KGP is not required, so don't log any warning if we can't find the version.
    }

    private fun configureMinSdkCheck(project: Project) {
        val androidComponents =
            project.extensions.findByType(AndroidComponentsExtension::class.java)

        androidComponents?.onVariants(
            androidComponents.selector().all()
        ) {
            val taskName = generateMinSdkCheckTaskName(it)
            val minSdkCheckTask =
                project.tasks.register(taskName) {
                    doLast {
                        val minSdkVersion = getMinSdkVersion(project, it)
                        try {
                            checkMinSdkVersion(minSdkVersion, project.rootDir.path, project.logger)
                        } catch (e: DependencyValidationException) {
                            project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true)
                            throw e
                        }
                    }
                }

            project.afterEvaluate {
                // Make assemble task depend on minSdkCheckTask for this variant.
                project.tasks
                    .named(generateAssembleTaskName(it))
                    .configure {
                        dependsOn(minSdkCheckTask)
                    }
            }
        }
    }

    private fun generateAssembleTaskName(it: Variant) = "$ASSEMBLE_PREFIX${FlutterPluginUtils.capitalize(it.name)}"

    private fun generateMinSdkCheckTaskName(it: Variant) = "${FlutterPluginUtils.capitalize(it.name)}$MIN_SDK_CHECK_TASK_POSTFIX"

    private fun getMinSdkVersion(
        project: Project,
        it: Variant
    ): MinSdkVersion {
        val agpVersion: AndroidPluginVersion? = VersionFetcher.getAGPVersion(project)
        // TODO(reidbaker): Remove version check as 8.3 is the minimum supported version.
        // Keeping the check around so that users that bypass will get the error message and not
        // a compile time error. See https://github.com/flutter/flutter/pull/171399
        return if (agpVersion != null && agpVersion.major >= 8 && agpVersion.minor >= 1) {
            MinSdkVersion(it.name, it.minSdk.apiLevel)
        } else {
            MinSdkVersion(it.name, it.minSdkVersion.apiLevel)
        }
    }

    @VisibleForTesting internal fun getErrorMessage(
        dependencyName: String,
        versionString: String,
        errorVersion: String,
        potentialFix: String
    ): String =
        "Error: Your project's $dependencyName version ($versionString) is lower " +
            "than Flutter's minimum supported version of $errorVersion. Please upgrade " +
            "your $dependencyName version. \nAlternatively, use the flag " +
            "\"--android-skip-build-dependency-validation\" to bypass this check.\n\n" +
            "Potential fix: $potentialFix"

    @VisibleForTesting internal fun getWarnMessage(
        dependencyName: String,
        versionString: String,
        warnVersion: String,
        potentialFix: String
    ): String =
        "Warning: Flutter support for your project's $dependencyName version " +
            "($versionString) will soon be dropped. Please upgrade your $dependencyName " +
            "version to a version of at least $warnVersion soon." +
            "\nAlternatively, use the flag \"--android-skip-build-dependency-validation\"" +
            " to bypass this check.\n\nPotential fix: $potentialFix"

    @VisibleForTesting
    internal fun getFlavorSpecificMessage(
        flavorName: String?,
        dependencyName: String
    ): String = dependencyName + (if (flavorName != null) " (flavor='$flavorName')" else "")

    @VisibleForTesting internal fun checkGradleVersion(
        version: Version,
        project: Project
    ) {
        if (version < errorGradleVersion) {
            val errorMessage: String =
                getErrorMessage(
                    GRADLE_NAME,
                    version.toString(),
                    errorGradleVersion.toString(),
                    getPotentialGradleFix(project.rootDir.path)
                )
            project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true)
            throw DependencyValidationException(errorMessage)
        } else if (version < warnGradleVersion) {
            val warnMessage: String =
                getWarnMessage(
                    GRADLE_NAME,
                    version.toString(),
                    warnGradleVersion.toString(),
                    getPotentialGradleFix(project.rootDir.path)
                )
            project.logger.error(warnMessage)
        }
    }

    @VisibleForTesting internal fun checkJavaVersion(
        version: JavaVersion,
        project: Project
    ) {
        if (version < errorJavaVersion) {
            val errorMessage: String =
                getErrorMessage(
                    JAVA_NAME,
                    version.toString(),
                    errorJavaVersion.toString(),
                    POTENTIAL_JAVA_FIX
                )
            project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true)
            throw DependencyValidationException(errorMessage)
        } else if (version < warnJavaVersion) {
            val warnMessage: String =
                getWarnMessage(
                    JAVA_NAME,
                    version.toString(),
                    warnJavaVersion.toString(),
                    POTENTIAL_JAVA_FIX
                )
            project.logger.error(warnMessage)
        }
    }

    @VisibleForTesting internal fun checkAGPVersion(
        androidPluginVersion: AndroidPluginVersion,
        project: Project
    ) {
        if (androidPluginVersion < errorAGPVersion) {
            val errorMessage: String =
                getErrorMessage(
                    AGP_NAME,
                    androidPluginVersion.toString(),
                    errorAGPVersion.toString(),
                    getPotentialAGPFix(project.rootDir.path)
                )
            project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true)
            throw DependencyValidationException(errorMessage)
        } else if (androidPluginVersion < warnAGPVersion) {
            val warnMessage: String =
                getWarnMessage(
                    AGP_NAME,
                    androidPluginVersion.toString(),
                    warnAGPVersion.toString(),
                    getPotentialAGPFix(project.rootDir.path)
                )
            project.logger.error(warnMessage)
        }
    }

    @VisibleForTesting internal fun checkKGPVersion(
        version: Version,
        project: Project
    ) {
        if (version < errorKGPVersion) {
            val errorMessage: String =
                getErrorMessage(
                    KGP_NAME,
                    version.toString(),
                    errorKGPVersion.toString(),
                    getPotentialKGPFix(project.rootDir.path)
                )
            project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true)
            throw DependencyValidationException(errorMessage)
        } else if (version < warnKGPVersion) {
            val warnMessage: String =
                getWarnMessage(
                    KGP_NAME,
                    version.toString(),
                    warnKGPVersion.toString(),
                    getPotentialKGPFix(project.rootDir.path)
                )
            project.logger.error(warnMessage)
        }
    }

    @VisibleForTesting internal fun checkMinSdkVersion(
        minSdkVersion: MinSdkVersion,
        projectDirectory: String,
        logger: Logger
    ) {
        // For Android SDK, only the major version is relevant, no need to do a full version check.
        if (minSdkVersion.version < errorMinSdkVersion) {
            val errorMessage: String =
                getErrorMessage(
                    getFlavorSpecificMessage(minSdkVersion.flavor, MIN_SDK_NAME),
                    minSdkVersion.version.toString(),
                    errorMinSdkVersion.toString(),
                    getPotentialSDKFix(projectDirectory)
                )
            throw DependencyValidationException(errorMessage)
        } else if (minSdkVersion.version < warnMinSdkVersion) {
            val warnMessage: String =
                getWarnMessage(
                    getFlavorSpecificMessage(minSdkVersion.flavor, MIN_SDK_NAME),
                    minSdkVersion.version.toString(),
                    warnMinSdkVersion.toString(),
                    getPotentialSDKFix(projectDirectory)
                )
            logger.error(warnMessage)
        }
    }
}

// Custom error for when the dependency_version_checker.kts script finds a dependency out of
// the defined support range.
@VisibleForTesting internal class DependencyValidationException(
    message: String? = null,
    cause: Throwable? = null
) : Exception(message, cause)

/**
 * Represents the minimum Android SDK version for a specific product flavor.
 *
 * @param flavor The product flavor name, or null for the default configuration.
 * @param version The minimum Android SDK version (API level).
 */
@VisibleForTesting internal class MinSdkVersion(
    val flavor: String,
    val version: Int
)
