#!/usr/bin/env python3
"""Choose the JDK the Android job installs, without breaking old toolchains.

The workflow used to hardcode the choice: 17 for Flutter, 21 for everything
else. That silently assumes every native Gradle app is new enough for JDK 21,
and an old one is not — Kotlin only learned to run kapt under JDK 21 in 1.9.20.
Older kapt reaches into javac internals that JDK 21 moved, so the build dies with

    Execution failed for task ':app:kaptGenerateStubsReleaseKotlin'
    > Internal compiler error. See log for more details

which names neither Kotlin nor the JDK. The same build is fine on 17.

Order of precedence:

1. the JAVA_VERSION repository variable, which always wins and is never
   second-guessed — an app that needs a specific JDK says so and that is that;
2. Flutter apps: 17, unchanged;
3. native Gradle apps whose Kotlin Gradle plugin predates JDK 21 support: 17;
4. everything else: 21, unchanged.

Rule 3 only ever fires on a build that is already broken, and only when the
Kotlin plugin version is positively identified. A project that explicitly asks
for Java 21 or newer keeps 21 even on old Kotlin: downgrading it would break a
build that currently works, and "change nothing that works" outranks the
kapt repair.
"""

from __future__ import annotations

import os
import re
from pathlib import Path

FLUTTER_JDK = "17"
GRADLE_JDK = "21"
LEGACY_KOTLIN_JDK = "17"

# Kotlin 1.9.20 is the first release that supports compiling *and* running kapt
# under JDK 21. Anything below it needs the older JDK.
KOTLIN_JDK21_FLOOR = (1, 9, 20)

VERSION = r"([0-9]+(?:\.[0-9]+){1,2})"

# Only the Kotlin *Gradle plugin* version decides this. Patterns deliberately
# require the plugin coordinate or a version-property assignment, so an
# unrelated Kotlin-adjacent dependency (kotlinx-coroutines 1.6.4, say) cannot
# masquerade as an old compiler and drag a modern repo down to JDK 17.
KOTLIN_PLUGIN_PATTERNS = (
    # ext.kotlin_version = '1.6.21'   |   kotlinVersion = "1.9.0"
    re.compile(rf"""kotlin[_-]?version\s*[=:]\s*["']{VERSION}["']""", re.IGNORECASE),
    # classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
    re.compile(rf"""kotlin-gradle-plugin["']?\s*:\s*["']?{VERSION}"""),
    # id 'org.jetbrains.kotlin.android' version '1.9.0'
    re.compile(rf"""org\.jetbrains\.kotlin[\w.]*["']\s*\)?\s*version\s*["']{VERSION}["']"""),
    # kotlin("android") version "1.9.0"
    re.compile(rf"""kotlin\(\s*["'][\w.-]+["']\s*\)\s*version\s*["']{VERSION}["']"""),
    # gradle/libs.versions.toml:  kotlin = "1.9.24"
    # buildSrc Config.kt:         const val kotlin = "1.3.61"
    # `kotlinx = "..."` deliberately does not match: the coroutines/serialization
    # libraries version independently of the compiler.
    re.compile(
        rf"""(?m)^\s*(?:(?:const|private|internal|public)\s+)*(?:va[lr]\s+)?"""
        rf"""kotlin(?:[_-]?version)?\s*=\s*["']{VERSION}["']""",
        re.IGNORECASE,
    ),
)

# An explicit request for Java 21+. If the project asks for it, it gets it —
# see the module docstring.
JAVA_TARGET_PATTERNS = (
    re.compile(r"jvmToolchain\s*\(\s*(?:JavaLanguageVersion\.of\s*\(\s*)?([0-9]{2})"),
    re.compile(r"languageVersion\s*=\s*JavaLanguageVersion\.of\s*\(\s*([0-9]{2})"),
    re.compile(r"JavaVersion\.VERSION_([0-9]{2})\b"),
    re.compile(
        r"""(?:sourceCompatibility|targetCompatibility|jvmTarget)\s*=?\s*["']?([0-9]{2})["']?"""
    ),
)

# Where a repo can declare its toolchain. Both roots are checked because the
# Gradle build lives at the repo root for a standalone Android app and under
# android/ for a repo that also holds another platform's sources — the same
# two candidates android_config.GRADLE_ROOT_CANDIDATES uses.
GRADLE_ROOTS = (".", "android")
ROOT_FILES = (
    "build.gradle",
    "build.gradle.kts",
    "gradle.properties",
    "gradle/libs.versions.toml",
)
MODULE_FILES = ("build.gradle", "build.gradle.kts")
BUILDSRC_FILE_LIMIT = 50


def parse_version(text: str) -> tuple[int, ...]:
    """'1.9' -> (1, 9, 0); '1.6.21' -> (1, 6, 21)."""
    parts = [int(piece) for piece in text.split(".")]
    while len(parts) < 3:
        parts.append(0)
    return tuple(parts[:3])


def candidate_files(workspace: Path) -> list[Path]:
    """Build files that could carry the Kotlin plugin or Java target."""
    found: list[Path] = []
    for relative in GRADLE_ROOTS:
        root = workspace / relative
        if not root.is_dir():
            continue
        for name in ROOT_FILES:
            path = root / name
            if path.is_file():
                found.append(path)
        # One level down catches :app and any sibling module. Deeper nesting is
        # not worth walking: a module that pins its own Kotlin plugin version
        # below the root's is vanishingly rare, and a miss just keeps today's
        # behaviour.
        for child in sorted(p for p in root.iterdir() if p.is_dir()):
            if child.name.startswith(".") or child.name == "build":
                continue
            for name in MODULE_FILES:
                path = child / name
                if path.is_file():
                    found.append(path)
        # A buildSrc repo keeps its versions in Kotlin source rather than in a
        # build file (`object Config { val kotlin = "1.3.61" }`), so the build
        # files above say nothing at all. Capped because this is the only place
        # we walk a tree of unknown depth.
        sources = sorted((root / "buildSrc" / "src").rglob("*.kt"))
        found.extend(sources[:BUILDSRC_FILE_LIMIT])
    return found


def _read(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return ""


def kotlin_plugin_version(workspace: Path) -> tuple[int, ...] | None:
    """Lowest Kotlin Gradle plugin version this repo declares, or None.

    Lowest rather than highest on purpose: if any module still compiles with an
    old Kotlin, that module's kapt is the one that will break under JDK 21.
    """
    found: list[tuple[int, ...]] = []
    for path in candidate_files(workspace):
        text = _read(path)
        for pattern in KOTLIN_PLUGIN_PATTERNS:
            found.extend(parse_version(match) for match in pattern.findall(text))
    return min(found) if found else None


def requests_java_21_or_newer(workspace: Path) -> bool:
    for path in candidate_files(workspace):
        text = _read(path)
        for pattern in JAVA_TARGET_PATTERNS:
            for match in pattern.findall(text):
                if int(match) >= 21:
                    return True
    return False


def select_jdk(workspace: Path, override: str, flutter: bool) -> tuple[str, str]:
    """Return (java-version, why) for actions/setup-java."""
    override = (override or "").strip()
    if override:
        # setup-java takes a bare major version. Anything else is a typo at best,
        # and a multi-line value would append extra keys to $GITHUB_OUTPUT and
        # invent step outputs, so refuse it rather than pass it through.
        if not override.isdigit():
            print(
                f"::warning::Ignoring JAVA_VERSION={override!r}; expected a major "
                "version number such as 17 or 21"
            )
        else:
            return override, "JAVA_VERSION repository variable"
    if flutter:
        return FLUTTER_JDK, "Flutter app"
    kotlin = kotlin_plugin_version(workspace)
    if kotlin is None:
        return GRADLE_JDK, "native Gradle app (Kotlin plugin version not declared in-repo)"
    pretty = ".".join(str(part) for part in kotlin)
    if kotlin >= KOTLIN_JDK21_FLOOR:
        return GRADLE_JDK, f"native Gradle app on Kotlin {pretty}"
    if requests_java_21_or_newer(workspace):
        return GRADLE_JDK, (
            f"native Gradle app on Kotlin {pretty}, which predates JDK 21 support, "
            f"but the project explicitly targets Java 21+"
        )
    return LEGACY_KOTLIN_JDK, (
        f"native Gradle app on Kotlin {pretty}; kapt only supports JDK 21 from "
        f"1.9.20, so this build needs JDK {LEGACY_KOTLIN_JDK}"
    )


def main() -> None:
    workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
    version, why = select_jdk(
        workspace,
        os.environ.get("JAVA_VERSION", ""),
        os.environ.get("FLUTTER_ENABLED", "") == "true",
    )
    print(f"Using JDK {version} — {why}")
    output = os.environ.get("GITHUB_OUTPUT")
    if output:
        with Path(output).open("a") as stream:
            stream.write(f"version={version}\n")


if __name__ == "__main__":
    main()
