#!/usr/bin/env python3
"""Raise a Flutter app's build toolchain to the versions its Flutter SDK requires.

The Flutter Gradle plugin refuses to apply when the project's wrapper is older
than the SDK's floor:

    Your project's Gradle version (8.11.1) is lower than Flutter's minimum
    supported version of 8.14.0.

CI installs the current `stable` Flutter, so that floor rises on Flutter's
release cadence, not the app's. Every app in a fleet then breaks on the same
day — long after the last commit that could have anticipated it.

This raises the wrapper inside the CI checkout, the same way the action already
rewrites versionCode there. The edit is never committed back; the project keeps
whatever version its authors chose, and the build stops depending on the
project having been touched since the last Flutter release.
"""

from __future__ import annotations

import re
from pathlib import Path

# `stable` moves the floor over time, so read it from the SDK rather than
# hardcoding a number this file would have to chase.
_CHECKER = Path("packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt")
# The same file declares a floor for each of the three: Gradle, the Android
# Gradle Plugin and the Kotlin Gradle Plugin. All three are hard errors, and
# all three move with `stable`.
_FLOORS = {
    "gradle": r"errorGradleVersion\s*:\s*Version\s*=\s*Version\((\d+),\s*(\d+),\s*(\d+)\)",
    "agp": r"errorAGPVersion\s*:\s*AndroidPluginVersion\s*=\s*AndroidPluginVersion\((\d+),\s*(\d+),\s*(\d+)\)",
    "kgp": r"errorKGPVersion\s*:\s*Version\s*=\s*Version\((\d+),\s*(\d+),\s*(\d+)\)",
}
_ERROR_VERSION = re.compile(_FLOORS["gradle"])
_DISTRIBUTION = re.compile(r"^(distributionUrl=.*gradle-)([0-9]+(?:\.[0-9]+)*)(-(?:all|bin)\.zip)$", re.M)

Version = tuple[int, ...]


def parse_version(text: str) -> Version | None:
    if not re.fullmatch(r"[0-9]+(\.[0-9]+)*", text or ""):
        return None
    return tuple(int(part) for part in text.split("."))


def _render(version: Version) -> str:
    return ".".join(str(part) for part in version)


def _stripped(version: Version) -> str:
    parts = list(version)
    while len(parts) > 2 and parts[-1] == 0:
        parts.pop()
    return _render(tuple(parts))


def candidates(version: Version) -> list[str]:
    """Distribution names to try, best guess first.

    Gradle's naming is not consistent across majors: 8.x drops a trailing zero
    patch (`gradle-8.14-all.zip`; `gradle-8.14.0-all.zip` is a 404) while 9.x
    keeps it (`gradle-9.0.0-all.zip`; `gradle-9.0-all.zip` is a 404). Getting
    this wrong swaps the error being fixed for a download failure that does not
    look like our doing, so order by the observed rule and keep the other form
    as a fallback.
    """
    stripped, full = _stripped(version), _render(version)
    order = [full, stripped] if version and version[0] >= 9 else [stripped, full]
    return list(dict.fromkeys(order))


def _exists(name: str) -> bool | None:
    """True/False if the distribution could be checked, None if we could not."""
    import urllib.error
    import urllib.request

    url = f"https://services.gradle.org/distributions/gradle-{name}-all.zip"
    request = urllib.request.Request(url, method="HEAD")
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            return response.status == 200
    except urllib.error.HTTPError as exc:
        return exc.code == 200
    except OSError:
        return None


def normalize(version: Version, probe=_exists) -> str:
    """The distribution name to write into distributionUrl.

    Probes services.gradle.org so a future naming change corrects itself; falls
    back to the ordering in `candidates` when the network is unavailable, which
    is no worse than not probing at all.
    """
    options = candidates(version)
    for name in options:
        if probe(name) is True:
            return name
    return options[0]


def flutter_minimum(flutter_root: Path, tool: str) -> Version | None:
    """The oldest `tool` (gradle | agp | kgp) this Flutter SDK will build against."""
    checker = flutter_root / _CHECKER
    try:
        text = checker.read_text(encoding="utf-8")
    except OSError:
        return None
    match = re.search(_FLOORS[tool], text)
    if not match:
        return None
    return tuple(int(group) for group in match.groups())


def flutter_minimum_gradle(flutter_root: Path) -> Version | None:
    """The oldest Gradle this Flutter SDK will apply its plugin against."""
    return flutter_minimum(flutter_root, "gradle")


def wrapper_properties(workspace: Path, gradle_root: Path | None = None) -> Path:
    root = gradle_root or (workspace / "android")
    return root / "gradle" / "wrapper" / "gradle-wrapper.properties"


def read_wrapper_version(properties: Path) -> Version | None:
    try:
        match = _DISTRIBUTION.search(properties.read_text(encoding="utf-8"))
    except OSError:
        return None
    return parse_version(match.group(2)) if match else None


def set_wrapper_version(properties: Path, version: Version) -> bool:
    try:
        text = properties.read_text(encoding="utf-8")
    except OSError:
        return False
    replaced, count = _DISTRIBUTION.subn(
        lambda m: f"{m.group(1)}{normalize(version)}{m.group(3)}", text
    )
    if not count:
        return False
    properties.write_text(replaced, encoding="utf-8")
    return True


def ensure_minimum(workspace: Path, flutter_root: Path, gradle_root: Path | None = None) -> str:
    """Raise the wrapper to Flutter's floor. Returns a line for the build log."""
    properties = wrapper_properties(workspace, gradle_root)
    if not properties.is_file():
        return ""
    minimum = flutter_minimum_gradle(flutter_root)
    if minimum is None:
        return (
            "::warning::Could not read Flutter's minimum Gradle version from "
            f"{flutter_root}; leaving the wrapper alone"
        )
    current = read_wrapper_version(properties)
    if current is None:
        return f"::warning::No Gradle distributionUrl found in {properties}; leaving it alone"
    if current >= minimum:
        return f"Gradle wrapper {normalize(current)} meets Flutter's minimum {normalize(minimum)}"
    if not set_wrapper_version(properties, minimum):
        return f"::warning::Failed to rewrite the Gradle distributionUrl in {properties}"
    return (
        f"::warning::Gradle wrapper {normalize(current)} is below Flutter's minimum "
        f"{normalize(minimum)}; raised it to {normalize(minimum)} for this build only. "
        f"Commit the bump in {properties.name} to make it permanent."
    )


# --- Android Gradle Plugin and Kotlin Gradle Plugin -------------------------
#
# Both are declared in `android/settings.gradle(.kts)` in the modern Flutter
# template and in the top-level `android/build.gradle` in the older one. Flutter
# floors both, and both floors move with `stable`, so an app that built fine
# last month stops building without a single line of it having changed.

_PLUGIN_BLOCK = {
    "agp": re.compile(
        r"""(id\s*\(?\s*["']com\.android\.application["']\s*\)?\s*version\s*["'])([0-9][0-9.]*)(["'])"""
    ),
    "kgp": re.compile(
        r"""(id\s*\(?\s*["']org\.jetbrains\.kotlin\.android["']\s*\)?\s*version\s*["'])([0-9][0-9.]*)(["'])"""
    ),
}
_LEGACY = {
    "agp": re.compile(r"""(["']com\.android\.tools\.build:gradle:)([0-9][0-9.]*)(["'])"""),
    "kgp": re.compile(r"""(ext\.kotlin_version\s*=\s*["'])([0-9][0-9.]*)(["'])"""),
}

_LABEL = {"agp": "Android Gradle Plugin", "kgp": "Kotlin Gradle Plugin"}


def toolchain_files(workspace: Path, gradle_root: Path | None = None) -> list[Path]:
    root = gradle_root or (workspace / "android")
    names = ("settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts")
    return [root / name for name in names if (root / name).is_file()]


def read_declared(files: list[Path], tool: str) -> tuple[Path, Version] | None:
    """First declaration of `tool` across the candidate build files."""
    for path in files:
        try:
            text = path.read_text(encoding="utf-8")
        except OSError:
            continue
        for pattern in (_PLUGIN_BLOCK[tool], _LEGACY[tool]):
            match = pattern.search(text)
            if match:
                version = parse_version(match.group(2))
                if version is not None:
                    return path, version
    return None


def set_declared(path: Path, tool: str, version: Version) -> bool:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return False
    total = 0
    for pattern in (_PLUGIN_BLOCK[tool], _LEGACY[tool]):
        text, count = pattern.subn(lambda m: f"{m.group(1)}{_render(version)}{m.group(3)}", text)
        total += count
    if not total:
        return False
    path.write_text(text, encoding="utf-8")
    return True


def ensure_toolchain(
    workspace: Path, flutter_root: Path, gradle_root: Path | None = None
) -> list[str]:
    """Raise Gradle, AGP and KGP to this Flutter SDK's floors. Returns log lines."""
    messages = []
    gradle = ensure_minimum(workspace, flutter_root, gradle_root)
    if gradle:
        messages.append(gradle)
    files = toolchain_files(workspace, gradle_root)
    if not files:
        return messages
    for tool in ("agp", "kgp"):
        minimum = flutter_minimum(flutter_root, tool)
        if minimum is None:
            messages.append(
                f"::warning::Could not read Flutter's minimum {_LABEL[tool]} version; leaving it alone"
            )
            continue
        found = read_declared(files, tool)
        if found is None:
            # Plenty of valid projects inherit these from elsewhere; that is not
            # something to fail a deploy over.
            continue
        path, current = found
        if current >= minimum:
            messages.append(
                f"{_LABEL[tool]} {_render(current)} meets Flutter's minimum {_render(minimum)}"
            )
            continue
        if not set_declared(path, tool, minimum):
            messages.append(f"::warning::Failed to raise the {_LABEL[tool]} version in {path}")
            continue
        messages.append(
            f"::warning::{_LABEL[tool]} {_render(current)} is below Flutter's minimum "
            f"{_render(minimum)}; raised it to {_render(minimum)} in {path.name} for this build "
            f"only. Commit the bump to make it permanent."
        )
    return messages
