import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option

// This extension does a lot of heavy lifting, because the property values (core/toolkits)
// aren't yet set when the plugin's `apply` method is called.
// In particular: setCore() also adds the base+core CDK dependencies (and computes the correct micronaut ksp dependency)
// setToolkits() also adds the toolkit dependencies.
class AirbyteBulkConnectorExtension {

    String core // 'extract' or 'load'
    List<String> toolkits = []  // 'extract-jdbc', etc...
    private boolean isDestination
    private String cdk
    private Project project

    AirbyteBulkConnectorExtension(Project project) {
        this.project = project
        isDestination = project.name.startsWith('destination-')

        this.cdk = project.findProperty("cdkVersion")
        if (cdk == null) {
            throw new IllegalArgumentException("Project should set the cdkVersion property in gradle.properties")
        }
        if (cdk != "local" && !(cdk.matches("^[0-9]+\\.[0-9]+") || cdk.matches("^[0-9]+\\.[0-9]+\\.[0-9]+"))) {
            throw new IllegalArgumentException("'cdk' should be either a well-formed version number or 'local'")
        }
    }

    String getCdk() {
        return cdk
    }

    void setCore(String core) {
        this.core = core
        if (core != "extract" && core != "load") {
            throw new IllegalArgumentException("'core' should be either 'extract' or 'load'")
        }

        project.processResources {
            // The metadata.yaml file is required by Micronaut.
            from(project.projectDir) {
                include 'metadata.yaml'
                duplicatesStrategy DuplicatesStrategy.EXCLUDE
            }
        }

        List<Project> fromSource = []
        List<String> fromJar = []
        if (cdk == "local") {
            fromSource = [
                    project.project(":airbyte-cdk:bulk:core:bulk-cdk-core-base"),
                    project.project(":airbyte-cdk:bulk:core:bulk-cdk-core-$core"),
            ]
        } else {
            fromJar = [
                    "io.airbyte.bulk-cdk:bulk-cdk-core-base:$cdk",
                    "io.airbyte.bulk-cdk:bulk-cdk-core-$core:$cdk",
            ]
        }

        String micronautVersion = null
        // Use a temporary configuration to find the Micronaut version
        // in the transitive dependencies of the Bulk CDK base.
        def tempConfiguration = project.configurations.detachedConfiguration()
        def baseDependency = (cdk == "local")
                ? project.dependencies.create(fromSource.first)
                : project.dependencies.create(fromJar.first)
        tempConfiguration.dependencies.add(baseDependency)
        tempConfiguration.resolvedConfiguration.firstLevelModuleDependencies.each {resolvedDependency ->
            resolvedDependency.children.each { transitiveDependency ->
                if (micronautVersion == null &&
                        transitiveDependency.moduleGroup == TARGET_GROUP &&
                        transitiveDependency.moduleName == TARGET_MODULE) {
                    micronautVersion = transitiveDependency.moduleVersion
                }
            }
        }
        if (micronautVersion == null) {
            throw new IllegalArgumentException(
                    "Micronaut version number not found; $TARGET_GROUP:$TARGET_MODULE is not a transitive dependency."
            )
        }
        final String kspDependency = "io.micronaut:micronaut-inject-kotlin:$micronautVersion"

        project.dependencies {
            ksp kspDependency
            kspTestFixtures kspDependency
            kspTest kspDependency
        }
        for (dep in fromSource + fromJar) {
            addDependency(dep)
        }
    }

    void setToolkits(List<String> toolkits) {
        this.toolkits = toolkits
        for (toolkit in toolkits) {
            if (cdk == "local") {
                if (toolkit.contains(':')) {
                    addDependency("io.airbyte.bulk-cdk:bulk-cdk-toolkit-$toolkit")
                } else {
                    addDependency(project.project(":airbyte-cdk:bulk:toolkits:bulk-cdk-toolkit-$toolkit"))
                }
            } else {
                if (toolkit.contains(':')) {
                    addDependency("io.airbyte.bulk-cdk:bulk-cdk-toolkit-$toolkit")
                } else {
                    addDependency("io.airbyte.bulk-cdk:bulk-cdk-toolkit-$toolkit:$cdk")
                }
            }
        }
    }

    void addDependency(dep) {
        // Copy the field, so that we can access it from within the closure
        // (because the closure runs within a DefaultDependencyHandler scope)
        final boolean isDestination = this.isDestination
        project.dependencies {
            implementation dep
            testFixturesImplementation dep
            testFixturesImplementation testFixtures(dep)
            testImplementation dep
            testImplementation testFixtures(dep)
            if (isDestination) {
                integrationTestImplementation dep
                integrationTestImplementation testFixtures(dep)
                integrationTestLegacyImplementation dep
                integrationTestLegacyImplementation testFixtures(dep)
            }
        }
    }

    static private final String TARGET_GROUP = "io.micronaut"
    static private final String TARGET_MODULE = "micronaut-runtime"
}

class AirbyteBulkConnectorPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {

        project.plugins.apply('application')

        project.application {
            applicationDefaultJvmArgs = [
                    '-XX:+ExitOnOutOfMemoryError',
                    '-XX:InitialRAMPercentage=75.0',
                    '-XX:MaxRAMPercentage=75.0',
            ]
        }

        if (project.name.startsWith('destination-')) {
            project.sourceSets {
                // This is the source set where most of our integration tests should live.
                integrationTest {
                    kotlin {
                        srcDir 'src/test-integration/kotlin'
                    }
                    resources {
                        srcDir 'src/test-integration/resources'
                    }
                }
                // This source set should only be used for tests based on the old CDK's test classes,
                // in particular DestinationAcceptanceTest / BaseTypingDedupingTest.
                integrationTestLegacy {
                    kotlin {
                        srcDir 'src/test-integration-legacy/kotlin'
                    }
                    resources {
                        srcDir 'src/test-integration-legacy/resources'
                    }
                }
            }

            // This task exists solely so that we can run the "modern" integration tests
            // in non-docker mode on our laptops.
            // It's not a dependency of any other task.
            project.tasks.register('integrationTestNonDocker', Test) {
                description = 'Runs the integration tests in non-docker mode.'
                group = 'verification'
                testClassesDirs = project.sourceSets.integrationTest.output.classesDirs
                classpath = project.sourceSets.integrationTest.runtimeClasspath
                useJUnitPlatform()

                jvmArgs = project.test.jvmArgs
                systemProperties = project.test.systemProperties
                maxParallelForks = project.test.maxParallelForks
                maxHeapSize = project.test.maxHeapSize

                testLogging() {
                    events 'skipped', 'started', 'passed', 'failed'
                    exceptionFormat 'full'
                    showStandardStreams = true
                }

                // Always re-run integration tests no matter what.
                outputs.upToDateWhen { false }
            }

            // For historical reasons (i.e. airbyte-ci), this task is called integrationTestJava.
            // airbyte-ci only wants to run a task with exactly that name.
            // There's intentionally no equivalent to run integrationTestNonDocker + integrationTestLegacy.
            // It's an actual Test task (rather than just depending on two sub-tasks integrationTestDocker + integrationTestLegacy)
            // so that gradle args (e.g. `--tests`) work as expected.
            project.tasks.register('integrationTestJava', Test) {
                description = 'Runs the integration tests in docker mode.'
                group = 'verification'
                testClassesDirs = project.sourceSets.integrationTest.output.classesDirs + project.sourceSets.integrationTestLegacy.output.classesDirs
                classpath = project.sourceSets.integrationTest.runtimeClasspath + project.sourceSets.integrationTestLegacy.runtimeClasspath
                useJUnitPlatform()
                // We need a docker image to run this task, so depend on assemble
                dependsOn project.tasks.assemble
                environment "AIRBYTE_CONNECTOR_INTEGRATION_TEST_RUNNER", "docker"

                jvmArgs = project.test.jvmArgs
                systemProperties = project.test.systemProperties
                maxParallelForks = project.test.maxParallelForks
                maxHeapSize = project.test.maxHeapSize

                testLogging() {
                    events 'skipped', 'started', 'passed', 'failed'
                    exceptionFormat 'full'
                    showStandardStreams = true
                }

                retry {
                    // If running in CI, enable retries to mitigate flakiness
                    if (System.getenv().containsKey("CI")) {
                        // Run each test method up to three times
                        maxRetries = 3
                        // If any test method fails, disable retries for the other tests
                        // (unintuitive: if you set this to 1, then as soon as a test case fails, it will disable retries, including for itself.
                        // So we set to 2, which means: a test case failing will retry itself. If, after three retries, it still fails, then we disable retries globally.)
                        maxFailures = 2
                    }
                    // If a test fails, but succeeds on retry - don't fail the task
                    failOnPassedAfterRetry = false
                }

                // Always re-run integration tests no matter what.
                outputs.upToDateWhen { false }
            }

            project.dependencies {
                // integration tests depend on the runtime code
                integrationTestImplementation project
                integrationTestLegacyImplementation project
                integrationTestLegacyImplementation project.sourceSets.integrationTest.output
            }

            project.tasks.named('build').configure {
                dependsOn project.tasks.integrationTestJava
            }

            project.configurations {
                integrationTestImplementation.extendsFrom implementation
                integrationTestImplementation.extendsFrom testImplementation
                integrationTestRuntimeOnly.extendsFrom runtimeOnly
                integrationTestRuntimeOnly.extendsFrom testRuntimeOnly
            }
        }

        project.tasks.matching {
                it.name == 'spotbugsIntegrationTestLegacy' ||
                it.name == 'spotbugsIntegrationTest' ||
                it.name == 'spotbugsTest'
            }.configureEach {
            enabled = false
        }

        project.tasks.named('check').configure {
            dependsOn project.tasks.matching { it.name ==~ /(compile|spotbugs)[a-zA-Z]*Java/ }
        }

        boolean withSlowTests = System.getProperty('skipSlowTests', 'false') == 'false'
        project.tasks.matching {
                it.name == 'test' ||
                it.name == 'integrationTestNonDocker' ||
                it.name == 'integrationTestJava'
            }.configureEach {
            onlyIf { withSlowTests }
        }

        project.configurations {
            testFixturesImplementation.extendsFrom implementation
            testFixturesRuntimeOnly.extendsFrom runtimeOnly
        }

        project.tasks.named('test').configure {
            // enable the "don't be evil" option, which for some reason is disabled by default
            systemProperties(["mockk.junit.extension.requireParallelTesting": "true"])
        }

        def extension = project.extensions.create('airbyteBulkConnector', AirbyteBulkConnectorExtension, project)

        project.tasks.register('getCdkVersion') {
            description = 'Gets the CDK version used by the connector'
            group = 'airbyte-bulk-connector'

            doLast {
                println extension.cdk
            }
        }

        project.tasks.register('upgradeCdk', BumpConnectorCdkVersion)
    }
}

class BumpConnectorCdkVersion extends DefaultTask {
    private String targetVersion = null

    BumpConnectorCdkVersion() {
        description = 'Bump the bulk CDK version number'
        group = 'airbyte-bulk-connector'
    }

    @Option(option = "cdkVersion", description = "The bulk CDK version to use. If not provided, defaults to the latest CDK version.")
    void setTargetVersion(String targetVersion) {
        this.targetVersion = targetVersion
    }

    @TaskAction
    void bumpVersion() {
        if (targetVersion == null) {
            targetVersion = getLocalCdkVersion(project)
            println "No version specified, upgrading to local CDK version: $targetVersion"
        } else {
            println "Version specified, using the provided CDK versionn: $targetVersion"
        }

        updateCdkVersion(project, targetVersion)
    }

    private static String getLocalCdkVersion(Project project) throws IOException {
        File versionPropsFile = new File(project.rootDir, 'airbyte-cdk/bulk/version.properties')
        if (!versionPropsFile.exists()) {
            throw new FileNotFoundException("CDK version file not found: ${versionPropsFile.absolutePath}")
        }

        Properties props = new Properties()
        versionPropsFile.withInputStream { stream -> props.load(stream) }
        if (!props.containsKey('version')) {
            throw new NoSuchElementException("Missing 'version' property in ${versionPropsFile.absolutePath}")
        }

        String localVersion = props.getProperty('version')?.trim()
        if (!localVersion) {
            throw new IllegalStateException("'version' property is empty in ${versionPropsFile.absolutePath}")
        }

        project.logger.info("Found local CDK version from version.properties: ${localVersion}")
        return localVersion
    }

    private static void updateCdkVersion(Project project, String version) {
        File propertiesFile = project.file('gradle.properties')

        // Unfortunately, we can't just use a Properties() object and load+store it,
        // because that would lose any comments in the file.
        // So we'll edit the file line-by-line.
        def lines = propertiesFile.readLines()
        def updatedLines = lines.collect { line ->
            if (line.startsWith('cdkVersion')) {
                return "cdkVersion=$version"
            } else {
                return line
            }
        }
        propertiesFile.text = updatedLines.join('\n') + '\n'

        project.logger.info("Updated CDK version to '${version}' in ${propertiesFile.absolutePath}")
    }
}
