#!/usr/bin/env python3
"""Pure configuration helpers for the Android delivery action."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path


class ConfigError(ValueError):
    """Raised when automatic Android configuration is ambiguous or invalid."""


@dataclass(frozen=True)
class SigningConfig:
    properties_path: Path
    keystore_path: Path
    store_password: str
    key_password: str
    key_alias: str


@dataclass(frozen=True)
class GradleProject:
    """A native (non-Flutter) Android project and its application module.

    ``root`` is the directory holding ``settings.gradle(.kts)`` and ``gradlew``
    — the repo root for a standalone Android repo, ``android/`` for a repo that
    keeps the Android app beside another platform's sources.
    """

    root: Path
    module: str
    build_file: Path

    @property
    def module_dir(self) -> Path:
        return self.build_file.parent

    @property
    def gradlew(self) -> Path:
        return self.root / "gradlew"


def parse_properties(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    for line_number, raw_line in enumerate(path.read_text().splitlines(), start=1):
        line = raw_line.strip()
        if not line or line.startswith(("#", "!")):
            continue
        match = re.match(r"([^:=\s]+)\s*[:=]\s*(.*)", line)
        if not match:
            raise ConfigError(
                f"invalid property syntax in {path.name} at line {line_number}"
            )
        values[match.group(1)] = match.group(2).strip()
    return values


def _single(candidates: list[Path], label: str) -> Path:
    if not candidates:
        raise ConfigError(f"no {label} found")
    if len(candidates) > 1:
        names = ", ".join(path.name for path in candidates)
        raise ConfigError(f"multiple {label} files found: {names}")
    return candidates[0]


def find_signing_config(workspace: Path) -> SigningConfig:
    creds = workspace / "creds"
    preferred = creds / "android-signing.properties"
    properties = preferred if preferred.is_file() else _single(
        sorted(creds.glob("*signing*.properties")), "Android signing properties"
    )
    values = parse_properties(properties)
    required = ("storePassword", "keyPassword", "keyAlias")
    missing = [key for key in required if not values.get(key)]
    if missing:
        raise ConfigError(
            f"{properties.name} is missing: {', '.join(missing)}"
        )

    configured_store = values.get("storeFile", "").strip()
    if configured_store:
        raw_path = Path(configured_store).expanduser()
        keystore = raw_path if raw_path.is_absolute() else properties.parent / raw_path
    else:
        keystore = _single(
            sorted([*creds.glob("*.jks"), *creds.glob("*.keystore")]),
            "Android upload keystore",
        )
    keystore = keystore.resolve()
    if not keystore.is_file():
        raise ConfigError(f"Android upload keystore does not exist: {keystore}")
    return SigningConfig(
        properties_path=properties.resolve(),
        keystore_path=keystore,
        store_password=values["storePassword"],
        key_password=values["keyPassword"],
        key_alias=values["keyAlias"],
    )


def find_play_service_account(workspace: Path) -> Path:
    matches: list[Path] = []
    for path in sorted((workspace / "creds").glob("*.json")):
        try:
            payload = json.loads(path.read_text())
        except (OSError, json.JSONDecodeError):
            continue
        if (
            payload.get("type") == "service_account"
            and payload.get("client_email")
            and payload.get("private_key")
        ):
            matches.append(path.resolve())
    return _single(matches, "Google Play service-account JSON")


APPLICATION_ID_PATTERNS = (
    re.compile(r"\bapplicationId\s*=\s*[\"']([^\"']+)[\"']"),
    re.compile(r"\bapplicationId\s+[\"']([^\"']+)[\"']"),
)

SETTINGS_FILES = ("settings.gradle.kts", "settings.gradle")
BUILD_FILES = ("build.gradle.kts", "build.gradle")

# Where the Android Gradle build can live relative to the repo root. A repo
# that is nothing but an Android app puts it at the root; a repo that also
# holds an iOS app (or any other platform) conventionally nests it in android/.
GRADLE_ROOT_CANDIDATES = (".", "android")


def project_kind(workspace: Path) -> str:
    """``flutter`` when a pubspec.yaml drives the build, else ``gradle``.

    Flutter wins the tie deliberately: a Flutter app also carries
    ``android/settings.gradle``, but it must be built through the Flutter tool,
    not by invoking Gradle directly.
    """
    return "flutter" if (workspace / "pubspec.yaml").is_file() else "gradle"


def _first_existing(directory: Path, names: tuple[str, ...]) -> Path | None:
    for name in names:
        candidate = directory / name
        if candidate.is_file():
            return candidate
    return None


def find_gradle_root(workspace: Path) -> Path:
    for relative in GRADLE_ROOT_CANDIDATES:
        candidate = (workspace / relative).resolve()
        if _first_existing(candidate, SETTINGS_FILES) and (candidate / "gradlew").is_file():
            return candidate
    raise ConfigError(
        "no Gradle build found: expected settings.gradle(.kts) and gradlew "
        "at the repo root or under android/"
    )


def _catalog_application_aliases(gradle_root: Path) -> set[str]:
    """Version-catalog plugin aliases that resolve to com.android.application.

    Modules increasingly declare the plugin as ``alias(libs.plugins.foo)``,
    which says nothing about the plugin id on its own — that lives in
    ``gradle/libs.versions.toml``. Without resolving it we cannot tell the
    application module from a library module.
    """
    catalog = gradle_root / "gradle" / "libs.versions.toml"
    if not catalog.is_file():
        return set()
    aliases: set[str] = set()
    in_plugins = False
    for raw_line in catalog.read_text().splitlines():
        line = raw_line.strip()
        if line.startswith("["):
            in_plugins = line.replace(" ", "") == "[plugins]"
            continue
        if not in_plugins or "=" not in line:
            continue
        alias, _, definition = line.partition("=")
        if re.search(r"""id\s*=\s*["']com\.android\.application["']""", definition):
            # Gradle exposes `my-plugin` / `my_plugin` as `libs.plugins.my.plugin`.
            aliases.add(re.sub(r"[-_]", ".", alias.strip()))
    return aliases


def _declares_application_plugin(build_file: Path, aliases: set[str]) -> bool:
    for raw_line in build_file.read_text().splitlines():
        line = raw_line.split("//", 1)[0]
        # `apply false` in a root build file only declares the plugin's version
        # for subprojects; it does not make that project an Android application.
        if "apply false" in line:
            continue
        if "com.android.application" in line:
            return True
        match = re.search(r"alias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)", line)
        if match and match.group(1) in aliases:
            return True
    return False


def _module_candidates(gradle_root: Path) -> list[Path]:
    directories = sorted(
        path
        for path in gradle_root.iterdir()
        if path.is_dir() and not path.name.startswith(".") and path.name != "buildSrc"
    )
    return [path for path in directories if _first_existing(path, BUILD_FILES)]


def find_gradle_project(workspace: Path) -> GradleProject:
    root = find_gradle_root(workspace)
    aliases = _catalog_application_aliases(root)
    matches: list[Path] = []
    for directory in _module_candidates(root):
        build_file = _first_existing(directory, BUILD_FILES)
        if build_file and _declares_application_plugin(build_file, aliases):
            matches.append(build_file)
    if not matches:
        raise ConfigError(
            f"no module under {root.name}/ applies com.android.application; "
            "cannot tell which module is the app"
        )
    if len(matches) > 1:
        preferred = [path for path in matches if path.parent.name == "app"]
        if len(preferred) != 1:
            names = ", ".join(sorted(path.parent.name for path in matches))
            raise ConfigError(f"multiple Android application modules found: {names}")
        matches = preferred
    build_file = matches[0].resolve()
    return GradleProject(
        root=root, module=f":{build_file.parent.name}", build_file=build_file
    )


def detect_package_name(workspace: Path, build_file: Path | None = None) -> str:
    if build_file is not None:
        gradle_files: tuple[Path, ...] = (build_file,)
        manifest = build_file.parent / "src" / "main" / "AndroidManifest.xml"
    else:
        gradle_files = (
            workspace / "android" / "app" / "build.gradle.kts",
            workspace / "android" / "app" / "build.gradle",
        )
        manifest = workspace / "android" / "app" / "src" / "main" / "AndroidManifest.xml"
    for path in gradle_files:
        if not path.is_file():
            continue
        text = path.read_text()
        for pattern in APPLICATION_ID_PATTERNS:
            match = pattern.search(text)
            if match:
                return match.group(1)

    if manifest.is_file():
        match = re.search(r"\bpackage\s*=\s*[\"']([^\"']+)[\"']", manifest.read_text())
        if match:
            return match.group(1)
    raise ConfigError(f"cannot detect Android applicationId from {gradle_files[0].parent}")


def detect_bundle(workspace: Path, search_root: Path | None = None) -> Path:
    # Flutter collects every module's outputs under build/app/; a plain Gradle
    # build leaves them in the module's own build/outputs/bundle/.
    base = search_root if search_root is not None else workspace / "build" / "app"
    candidates = sorted((base / "outputs" / "bundle").glob("**/*.aab"))
    return _single(candidates, "release Android App Bundle")


VERSION_CODE_RE = re.compile(r"(?m)^(?P<pre>\s*versionCode\s*=?\s*)(?P<value>\d+)")
VERSION_NAME_RE = re.compile(
    r"(?m)^(?P<pre>\s*versionName\s*=?\s*)(?P<quote>[\"'])(?P<value>[^\"']*)(?P=quote)"
)


def set_gradle_version(
    build_file: Path, version_code: int, version_name: str | None = None
) -> dict[str, bool]:
    """Pin versionCode (and optionally versionName) in a module build file.

    Gradle has no equivalent of `flutter build --build-number`, and a literal
    `versionCode = 1` checked into the repo means every run after the first
    is rejected by Play as a duplicate. Rewriting the literal in the checkout
    is the Gradle counterpart to the iOS action patching the pbxproj.
    """
    original = build_file.read_text()
    patched, code_hits = VERSION_CODE_RE.subn(
        lambda match: f"{match.group('pre')}{version_code}", original
    )
    name_hits = 0
    if version_name:
        patched, name_hits = VERSION_NAME_RE.subn(
            lambda match: (
                f"{match.group('pre')}{match.group('quote')}"
                f"{version_name}{match.group('quote')}"
            ),
            patched,
        )
    if patched != original:
        build_file.write_text(patched)
    return {"version_code": bool(code_hits), "version_name": bool(name_hits)}
