#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Abhishek Sengar
import argparse
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple

DESCRIPTION = (
    "Generate a minimal Jetpack Compose Android project from a Kotlin source folder (or single file)."
)

USAGE_EXAMPLES = """
Usage Examples:
  1. Generate project from a source directory of Kotlin files:
     python3 android_scaffold.py ./src_folder

  2. Generate project and immediately build the Debug APK:
     python3 android_scaffold.py ./src_folder --build-apk

  3. Start an installed Android Emulator, build, install, and launch the app:
     python3 android_scaffold.py ./src_folder --run

  4. Run on the emulator and reload automatically whenever a Kotlin file changes:
     python3 android_scaffold.py ./src_folder --watch

  5. List installed Android Virtual Devices (AVDs) or boot a specific one:
     python3 android_scaffold.py ./src_folder --list-emulators
     python3 android_scaffold.py ./src_folder --start-emulator Pixel_7_API_34
"""

APK_EXPORT_FOLDER_NAME = "apk"
DEFAULT_OUTPUT_DIR = "./android_build"
DEFAULT_APP_NAME = "ThumbInventory"
DEFAULT_PACKAGE_NAME = "com.example.singlefileapp"
PREFERRED_LAUNCHER_ACTIVITY_NAME = "MainActivity"
FIRST_AVAILABLE_AVD = "__FIRST_AVAILABLE__"

GRADLE_VERSION = "9.9.0"
ANDROID_GRADLE_PLUGIN_VERSION = "9.5.1"
KOTLIN_COMPOSE_PLUGIN_VERSION = "2.4.21"
COMPILE_SDK_VERSION = 36
TARGET_SDK_VERSION = 36
MIN_SDK_VERSION = 26

BOOT_POLL_ATTEMPTS = 90
BOOT_POLL_INTERVAL_SECONDS = 2
COMMAND_REFERENCE_WIDTH = 76

WATCH_POLL_INTERVAL_SECONDS = 1.0
WATCH_SETTLE_SECONDS = 0.5

ANDROID_SDK_ENVIRONMENT_VARIABLES = ("ANDROID_HOME", "ANDROID_SDK_ROOT")
MARKDOWN_CODE_FENCE = "```"
ADB_HEADER_LINE_COUNT = 1
ADB_READY_STATE = "device"
BOOT_COMPLETED_VALUE = "1"

ACTIVITY_CLASS_PATTERN = re.compile(
    r"\bclass\s+([A-Za-z0-9_]+)\s*(?:<[^>]*>)?\s*(?:\([^)]*\))?\s*:\s*"
    r"(?:androidx\.activity\.)?(?:androidx\.appcompat\.app\.)?(?:android\.app\.)?"
    r"(?:ComponentActivity|AppCompatActivity|FragmentActivity|Activity)\b"
)
PACKAGE_DECLARATION_PATTERN = re.compile(r"^\s*package\s+([a-zA-Z0-9_.]+)", re.MULTILINE)

GRADLE_WRAPPER_JAR_URL = (
    f"https://raw.githubusercontent.com/gradle/gradle/v{GRADLE_VERSION}/gradle/wrapper/gradle-wrapper.jar"
)

GRADLE_WRAPPER_PROPERTIES = f"""
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\\://services.gradle.org/distributions/gradle-{GRADLE_VERSION}-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
"""

GRADLEW_POSIX_SCRIPT = """#!/bin/sh
# Minimal POSIX Gradle Wrapper launcher
APP_HOME=$(cd "${0%/*}" && pwd -P)
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"

if [ -n "$JAVA_HOME" ] ; then
    JAVACMD="$JAVA_HOME/bin/java"
else
    JAVACMD="java"
fi

exec "$JAVACMD" -Xmx64m -Xms64m -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
"""

GRADLEW_WINDOWS_SCRIPT = """@rem Minimal Windows Gradle Wrapper launcher
@echo off
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_HOME=%DIRNAME%
set CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar

if defined JAVA_HOME (
    set JAVACMD="%JAVA_HOME%\\bin\\java.exe"
) else (
    set JAVACMD="java.exe"
)

%JAVACMD% -Xmx64m -Xms64m -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"""

ROOT_BUILD_GRADLE = f"""
plugins {{
    id("com.android.application") version "{ANDROID_GRADLE_PLUGIN_VERSION}" apply false
    id("org.jetbrains.kotlin.plugin.compose") version "{KOTLIN_COMPOSE_PLUGIN_VERSION}" apply false
}}
"""

GRADLE_PROPERTIES = """
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
"""

LAUNCHER_ICON_XML = """
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    <path
        android:fillColor="#1E2228"
        android:pathData="M0,0h108v108h-108z"/>
    <path
        android:fillColor="#3B82F6"
        android:pathData="M28,28h22v22h-22z"/>
    <path
        android:fillColor="#10B981"
        android:pathData="M58,28h22v22h-22z"/>
    <path
        android:fillColor="#F59E0B"
        android:pathData="M28,58h22v22h-22z"/>
    <path
        android:fillColor="#EC4899"
        android:pathData="M58,58h22v22h-22z"/>
</vector>
"""

GITIGNORE_CONTENT = """
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
.kotlin/

# Local machine configuration
local.properties

# Android Studio / IntelliJ
.idea/
*.iml
*.ipr
*.iws
.navigation/
captures/
.externalNativeBuild/
.cxx/

# Build outputs
*.apk
*.aab
*.ap_
*.dex
*.class
output.json
release/

# Signing keys
*.jks
*.keystore
keystore.properties

# Logs and crash dumps
*.log
*.hprof
hs_err_pid*

# Google Services / Firebase secrets
google-services.json

# Lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/

# OS files
.DS_Store
Thumbs.db
"""


@dataclass(frozen=True)
class ActivityDeclaration:
    package_name: str
    class_name: str

    @property
    def qualified_name(self) -> str:
        return f"{self.package_name}.{self.class_name}"


@dataclass(frozen=True)
class KotlinSourceFile:
    source_path: Path
    package_name: str
    activity_names: List[str]
    code: str


@dataclass(frozen=True)
class KotlinProject:
    base_package: str
    launcher_activity: ActivityDeclaration
    activities: List[ActivityDeclaration]
    files: List[KotlinSourceFile]

    @property
    def launch_component(self) -> str:
        return f"{self.base_package}/{self.launcher_activity.qualified_name}"


@dataclass(frozen=True)
class ScaffoldRequest:
    source_path: Path
    output_dir: Path
    app_name: str
    sdk_path: Optional[Path]


@dataclass(frozen=True)
class ApkBuildSettings:
    project_dir: Path
    apk_export_dir: Optional[Path]
    is_release: bool = False


@dataclass(frozen=True)
class AndroidToolchain:
    sdk_path: Optional[Path]

    @property
    def adb(self) -> str:
        return _resolve_sdk_tool(self.sdk_path, "platform-tools", "adb")

    @property
    def emulator(self) -> str:
        return _resolve_sdk_tool(self.sdk_path, "emulator", "emulator")


@dataclass(frozen=True)
class WatchSession:
    scaffold_request: ScaffoldRequest
    build_settings: ApkBuildSettings
    toolchain: AndroidToolchain
    preferred_avd: Optional[str]


SourceSnapshot = Dict[Path, Tuple[int, int]]


@dataclass(frozen=True)
class _DeclaredKotlinFile:
    source_path: Path
    declared_package: Optional[str]
    activity_names: List[str]
    code: str


@dataclass(frozen=True)
class _SourceSyncReport:
    updated_file_names: List[str]
    unchanged_count: int
    removed_file_names: List[str]


def main() -> None:
    arguments = _build_argument_parser().parse_args()
    toolchain = AndroidToolchain(_detect_android_sdk())

    if arguments.list_emulators:
        _print_installed_avds(toolchain)
        return

    scaffold_request = _scaffold_request(arguments, toolchain)
    project = _generate_project_or_exit(scaffold_request)
    _print_command_reference(arguments.output, toolchain, project, _script_invocation())

    preferred_avd = _requested_avd_name(arguments.start_emulator)
    build_settings = ApkBuildSettings(
        project_dir=arguments.output,
        apk_export_dir=arguments.apk_dir or _default_apk_export_dir(arguments.source_path),
    )

    if arguments.watch:
        watch_and_reload(WatchSession(scaffold_request, build_settings, toolchain, preferred_avd), project)
        return
    if arguments.run:
        install_and_run_app(build_settings, toolchain, project, preferred_avd)
        return
    if arguments.start_emulator is not None:
        start_android_emulator(toolchain, preferred_avd)
    if arguments.build_apk:
        build_apk(build_settings)


def generate_android_project(request: ScaffoldRequest) -> KotlinProject:
    output_dir = request.output_dir.resolve()
    is_existing_scaffold = (output_dir / "settings.gradle.kts").exists()
    project = _scan_kotlin_sources(request.source_path)
    _print_scan_summary(request.source_path, output_dir, project, is_existing_scaffold)

    output_dir.mkdir(parents=True, exist_ok=True)
    _write_build_configuration(output_dir, request, project.base_package)
    _write_app_resources(output_dir, request.app_name, project)
    sync_report = _sync_kotlin_sources(output_dir / "app" / "src" / "main" / "java", project.files)
    _print_sync_report(sync_report)
    _bootstrap_gradle_wrapper(output_dir)
    return project


def list_installed_avds(toolchain: AndroidToolchain) -> List[str]:
    try:
        result = subprocess.run(
            [toolchain.emulator, "-list-avds"],
            capture_output=True,
            text=True,
            check=True
        )
    except Exception as error:
        print(f"[!] Failed to list emulators using '{toolchain.emulator}': {error}")
        return []
    output_lines = [line.strip() for line in result.stdout.splitlines()]
    return [line for line in output_lines if line and not line.startswith("INFO")]


def start_android_emulator(toolchain: AndroidToolchain, preferred_avd: Optional[str] = None) -> bool:
    connected_devices = _connected_device_serials(toolchain)
    if connected_devices and not preferred_avd:
        print(f"[*] Connected Android device/emulator already online: {connected_devices[0]}")
        return True

    avd_names = list_installed_avds(toolchain)
    if not avd_names:
        print("[!] No installed Android Virtual Devices (AVDs) found.")
        print("    Create one in Android Studio -> Device Manager or via 'avdmanager'.")
        return False

    target_avd = _choose_avd(avd_names, preferred_avd)
    _launch_emulator(toolchain, target_avd)
    return _wait_for_boot_completion(toolchain)


def build_apk(settings: ApkBuildSettings) -> Optional[Path]:
    project_dir = settings.project_dir.resolve()
    gradlew_path = project_dir / _gradlew_script_name()
    if not gradlew_path.exists():
        _bootstrap_gradle_wrapper(project_dir)

    variant = "release" if settings.is_release else "debug"
    task = f"assemble{variant.capitalize()}"

    print(f"\n[*] Running Gradle task '{task}' in {project_dir} ...")
    result = subprocess.run([str(gradlew_path), task], cwd=str(project_dir))
    if result.returncode != 0:
        print(f"[!] APK build failed with exit code {result.returncode}.")
        return None

    built_apks = list((project_dir / "app" / "build" / "outputs" / "apk" / variant).glob("*.apk"))
    if not built_apks:
        return None

    built_apk = built_apks[0].resolve()
    print(f"[+] APK successfully built: {built_apk}")
    if settings.apk_export_dir:
        _export_apk(built_apk, settings.apk_export_dir)
    return built_apk


def install_and_run_app(
    build_settings: ApkBuildSettings,
    toolchain: AndroidToolchain,
    project: KotlinProject,
    preferred_avd: Optional[str] = None
) -> None:
    if not start_android_emulator(toolchain, preferred_avd):
        return
    if _install_and_launch(build_settings, toolchain, project):
        print("[+] App is now running on the emulator!")


def watch_and_reload(session: WatchSession, project: KotlinProject) -> None:
    if not start_android_emulator(session.toolchain, session.preferred_avd):
        return

    source_path = session.scaffold_request.source_path
    snapshot = _snapshot_sources(source_path)
    _install_and_launch(session.build_settings, session.toolchain, project)
    print(f"\n[*] Watching '{source_path}' for Kotlin changes. Press Ctrl+C to stop.")

    try:
        while True:
            time.sleep(WATCH_POLL_INTERVAL_SECONDS)
            current_snapshot = _snapshot_sources(source_path)
            if current_snapshot == snapshot:
                continue
            settled_snapshot = _wait_until_settled(source_path, current_snapshot)
            print(f"\n[*] Changed: {', '.join(_describe_changes(snapshot, settled_snapshot))}")
            snapshot = settled_snapshot
            _reload(session)
    except KeyboardInterrupt:
        print("\n[*] Watch mode stopped.")


def _install_and_launch(
    build_settings: ApkBuildSettings,
    toolchain: AndroidToolchain,
    project: KotlinProject
) -> bool:
    apk_path = build_apk(build_settings)
    if not apk_path:
        return False

    print(f"[*] Installing {apk_path.name} onto device/emulator...")
    install_result = subprocess.run([toolchain.adb, "install", "-r", str(apk_path)], check=False)
    if install_result.returncode != 0:
        print(f"[!] Install failed with exit code {install_result.returncode}.")
        return False

    print(f"[*] Launching activity '{project.launch_component}'...")
    launch_result = subprocess.run(
        [toolchain.adb, "shell", "am", "start", "-S", "-n", project.launch_component],
        check=False
    )
    return launch_result.returncode == 0


def _reload(session: WatchSession) -> None:
    started_at = time.monotonic()
    try:
        project = generate_android_project(session.scaffold_request)
    except Exception as error:
        print(f"[!] Could not sync sources: {error}")
        print("[*] Still watching. Fix the source and save again.")
        return

    if _install_and_launch(session.build_settings, session.toolchain, project):
        print(f"[+] Reloaded in {time.monotonic() - started_at:.1f}s. Watching for changes...")
    else:
        print("[*] Reload failed. Still watching. Fix the error above and save again.")


def _snapshot_sources(source_path: Path) -> SourceSnapshot:
    snapshot: SourceSnapshot = {}
    for path in _kotlin_source_paths(source_path):
        try:
            file_status = path.stat()
        except FileNotFoundError:
            continue
        snapshot[path] = (file_status.st_mtime_ns, file_status.st_size)
    return snapshot


def _wait_until_settled(source_path: Path, snapshot: SourceSnapshot) -> SourceSnapshot:
    while True:
        time.sleep(WATCH_SETTLE_SECONDS)
        next_snapshot = _snapshot_sources(source_path)
        if next_snapshot == snapshot:
            return snapshot
        snapshot = next_snapshot


def _describe_changes(previous: SourceSnapshot, current: SourceSnapshot) -> List[str]:
    added = [f"+{path.name}" for path in current if path not in previous]
    removed = [f"-{path.name}" for path in previous if path not in current]
    modified = [path.name for path in current if path in previous and previous[path] != current[path]]
    return modified + added + removed


def _build_argument_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=USAGE_EXAMPLES,
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "source_path",
        type=Path,
        help="Path to a source folder containing Kotlin files/Activities (or a single .kt file)"
    )
    parser.add_argument(
        "-o", "--output",
        type=Path,
        default=DEFAULT_OUTPUT_DIR,
        help=f"Output directory for the generated Android project (default: {DEFAULT_OUTPUT_DIR})"
    )
    parser.add_argument(
        "-n", "--name",
        type=str,
        default=DEFAULT_APP_NAME,
        help=f"Application display name (default: {DEFAULT_APP_NAME})"
    )
    parser.add_argument(
        "--list-emulators",
        action="store_true",
        help="List all installed Android Virtual Devices (AVDs) and exit"
    )
    parser.add_argument(
        "--start-emulator",
        nargs="?",
        const=FIRST_AVAILABLE_AVD,
        metavar="AVD_NAME",
        help="Start an installed Android emulator (optionally specify AVD name)"
    )
    parser.add_argument(
        "--build-apk",
        action="store_true",
        help="Immediately build the Debug APK after generating the project"
    )
    parser.add_argument(
        "--run",
        action="store_true",
        help="Boot an emulator (if needed), build the APK, install it, and launch the app"
    )
    parser.add_argument(
        "--watch",
        action="store_true",
        help="Like --run, then rebuild, reinstall and relaunch the app whenever a Kotlin file changes"
    )
    parser.add_argument(
        "--apk-dir",
        type=Path,
        default=None,
        help=f"Folder to copy the built APK into (default: '{APK_EXPORT_FOLDER_NAME}' next to the source folder)"
    )
    return parser


def _print_installed_avds(toolchain: AndroidToolchain) -> None:
    avd_names = list_installed_avds(toolchain)
    print("Installed Android Emulators (AVDs):")
    for avd_name in avd_names:
        print(f"  - {avd_name}")
    if not avd_names:
        print("  (None found)")


def _scaffold_request(arguments: argparse.Namespace, toolchain: AndroidToolchain) -> ScaffoldRequest:
    return ScaffoldRequest(
        source_path=arguments.source_path,
        output_dir=arguments.output,
        app_name=arguments.name,
        sdk_path=toolchain.sdk_path
    )


def _generate_project_or_exit(request: ScaffoldRequest) -> KotlinProject:
    try:
        return generate_android_project(request)
    except Exception as error:
        print(f"[!] Error generating project: {error}")
        sys.exit(1)


def _script_invocation() -> str:
    return f"python3 {Path(sys.argv[0]).name} {sys.argv[1]}"


def _requested_avd_name(start_emulator_argument: Optional[str]) -> Optional[str]:
    if start_emulator_argument in (None, FIRST_AVAILABLE_AVD):
        return None
    return start_emulator_argument


def _default_apk_export_dir(source_path: Path) -> Path:
    return source_path.resolve().parent / APK_EXPORT_FOLDER_NAME


def _is_windows() -> bool:
    return platform.system() == "Windows"


def _gradlew_script_name() -> str:
    return "gradlew.bat" if _is_windows() else "gradlew"


def _detect_android_sdk() -> Optional[Path]:
    for environment_variable in ANDROID_SDK_ENVIRONMENT_VARIABLES:
        configured_path = os.environ.get(environment_variable)
        if configured_path and Path(configured_path).exists():
            return Path(configured_path).resolve()

    for candidate in _standard_sdk_locations():
        if candidate.exists():
            return candidate.resolve()

    return None


def _standard_sdk_locations() -> List[Path]:
    home = Path.home()
    operating_system = platform.system()

    if operating_system == "Darwin":
        return [home / "Library" / "Android" / "sdk"]

    if operating_system == "Windows":
        local_app_data = os.environ.get("LOCALAPPDATA")
        locations = [Path(local_app_data) / "Android" / "Sdk"] if local_app_data else []
        return locations + [home / "AppData" / "Local" / "Android" / "Sdk"]

    return [home / "Android" / "Sdk", home / "android-sdk", Path("/opt/android-sdk")]


def _resolve_sdk_tool(sdk_path: Optional[Path], subfolder: str, tool_name: str) -> str:
    executable_name = f"{tool_name}.exe" if _is_windows() else tool_name

    if sdk_path:
        candidate = sdk_path / subfolder / executable_name
        if candidate.exists():
            return str(candidate)

    return shutil.which(executable_name) or shutil.which(tool_name) or executable_name


def _scan_kotlin_sources(source_path: Path) -> KotlinProject:
    declared_files = [_read_kotlin_file(path) for path in _find_kotlin_files(source_path)]
    base_package = _choose_base_package(declared_files)
    files = [_assign_package(declared_file, base_package) for declared_file in declared_files]
    activities = _collect_activities(files, base_package)
    return KotlinProject(
        base_package=base_package,
        launcher_activity=_choose_launcher_activity(activities),
        activities=activities,
        files=files
    )


def _find_kotlin_files(source_path: Path) -> List[Path]:
    if not source_path.exists():
        raise FileNotFoundError(f"Source path not found: {source_path}")

    kotlin_files = _kotlin_source_paths(source_path)
    if not kotlin_files:
        raise ValueError(f"No Kotlin (.kt) source files found in: {source_path}")
    return kotlin_files


def _kotlin_source_paths(source_path: Path) -> List[Path]:
    if source_path.is_file():
        return [source_path]
    if not source_path.is_dir():
        return []
    return sorted(
        path for path in source_path.rglob("*.kt")
        if path.is_file() and not path.name.endswith(".gradle.kts")
    )


def _read_kotlin_file(path: Path) -> _DeclaredKotlinFile:
    code = _strip_markdown_fences(path.read_text(encoding="utf-8"))
    package_match = PACKAGE_DECLARATION_PATTERN.search(code)
    return _DeclaredKotlinFile(
        source_path=path,
        declared_package=package_match.group(1) if package_match else None,
        activity_names=ACTIVITY_CLASS_PATTERN.findall(code),
        code=code
    )


def _strip_markdown_fences(raw_text: str) -> str:
    code_lines = [
        line for line in raw_text.splitlines()
        if not line.strip().startswith(MARKDOWN_CODE_FENCE)
    ]
    return "\n".join(code_lines).strip() + "\n"


def _choose_base_package(declared_files: List[_DeclaredKotlinFile]) -> str:
    for declared_file in declared_files:
        if PREFERRED_LAUNCHER_ACTIVITY_NAME in declared_file.activity_names and declared_file.declared_package:
            return declared_file.declared_package

    declared_packages = [file.declared_package for file in declared_files if file.declared_package]
    if declared_packages:
        return min(declared_packages, key=len)
    return DEFAULT_PACKAGE_NAME


def _assign_package(declared_file: _DeclaredKotlinFile, base_package: str) -> KotlinSourceFile:
    if declared_file.declared_package:
        package_name = declared_file.declared_package
        code = declared_file.code
    else:
        package_name = base_package
        code = f"package {base_package}\n\n{declared_file.code}"

    return KotlinSourceFile(
        source_path=declared_file.source_path,
        package_name=package_name,
        activity_names=declared_file.activity_names,
        code=code
    )


def _collect_activities(files: List[KotlinSourceFile], base_package: str) -> List[ActivityDeclaration]:
    activities: List[ActivityDeclaration] = []
    for file in files:
        for activity_name in file.activity_names:
            activity = ActivityDeclaration(file.package_name, activity_name)
            if activity not in activities:
                activities.append(activity)

    if not activities:
        activities.append(ActivityDeclaration(base_package, PREFERRED_LAUNCHER_ACTIVITY_NAME))
    return activities


def _choose_launcher_activity(activities: List[ActivityDeclaration]) -> ActivityDeclaration:
    for activity in activities:
        if activity.class_name == PREFERRED_LAUNCHER_ACTIVITY_NAME:
            return activity
    return activities[0]


def _print_scan_summary(
    source_path: Path,
    output_dir: Path,
    project: KotlinProject,
    is_existing_scaffold: bool
) -> None:
    scaffold_mode = (
        "Reusing existing scaffold (incremental sync)" if is_existing_scaffold else "Creating new scaffold"
    )
    launcher_name = project.launcher_activity.qualified_name

    print(f"[*] Scanned source '{source_path}':")
    print(f"    - Scaffold Mode     : {scaffold_mode}")
    print(f"    - Kotlin Files      : {len(project.files)} file(s)")
    print(f"    - Base Package      : {project.base_package}")
    print(f"    - Launcher Activity : {launcher_name}")
    if len(project.activities) > 1:
        other_activity_names = [
            activity.qualified_name for activity in project.activities
            if activity.qualified_name != launcher_name
        ]
        print(f"    - Other Activities  : {', '.join(other_activity_names)}")
    print(f"    - Output Directory  : {output_dir}")


def _write_build_configuration(output_dir: Path, request: ScaffoldRequest, base_package: str) -> None:
    _write_file_if_changed(output_dir / "settings.gradle.kts", _render_settings_gradle(request.app_name))
    _write_file_if_changed(output_dir / "build.gradle.kts", ROOT_BUILD_GRADLE)
    _write_file_if_changed(output_dir / "gradle.properties", GRADLE_PROPERTIES)
    _write_file_if_changed(output_dir / ".gitignore", GITIGNORE_CONTENT)
    if request.sdk_path:
        _write_file_if_changed(output_dir / "local.properties", _render_local_properties(request.sdk_path))
    _write_file_if_changed(output_dir / "app" / "build.gradle.kts", _render_app_build_gradle(base_package))


def _write_app_resources(output_dir: Path, app_name: str, project: KotlinProject) -> None:
    main_dir = output_dir / "app" / "src" / "main"
    _write_file_if_changed(main_dir / "res" / "drawable" / "ic_launcher.xml", LAUNCHER_ICON_XML)
    _write_file_if_changed(main_dir / "AndroidManifest.xml", _render_manifest(app_name, project))


def _render_settings_gradle(app_name: str) -> str:
    return f"""
pluginManagement {{
    repositories {{
        google()
        mavenCentral()
        gradlePluginPortal()
    }}
}}
dependencyResolutionManagement {{
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {{
        google()
        mavenCentral()
    }}
}}

rootProject.name = "{app_name}"
include(":app")
"""


def _render_local_properties(sdk_path: Path) -> str:
    escaped_sdk_path = str(sdk_path).replace("\\", "\\\\")
    return f"sdk.dir={escaped_sdk_path}\n"


def _render_app_build_gradle(base_package: str) -> str:
    return f"""
plugins {{
    id("com.android.application")
    id("org.jetbrains.kotlin.plugin.compose")
}}

android {{
    namespace = "{base_package}"
    compileSdk = {COMPILE_SDK_VERSION}

    defaultConfig {{
        applicationId = "{base_package}"
        minSdk = {MIN_SDK_VERSION}
        targetSdk = {TARGET_SDK_VERSION}
        versionCode = 1
        versionName = "1.0"
    }}

    buildTypes {{
        release {{
            isMinifyEnabled = false
        }}
    }}

    compileOptions {{
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }}

    buildFeatures {{
        compose = true
    }}
}}

kotlin {{
    compilerOptions {{
        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    }}
}}

dependencies {{
    val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
    implementation(composeBom)

    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("androidx.activity:activity-compose:1.9.2")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}}
"""


def _render_manifest(app_name: str, project: KotlinProject) -> str:
    activity_entries = "\n\n".join(
        _render_activity_entry(activity, is_launcher=activity == project.launcher_activity)
        for activity in project.activities
    )
    return f"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Permissions for Offline Local Wi-Fi / Mobile Hotspot Peer-to-Peer CSV Transfer -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="{app_name}"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.DeviceDefault.NoActionBar">

{activity_entries}

    </application>
</manifest>
"""


def _render_activity_entry(activity: ActivityDeclaration, is_launcher: bool) -> str:
    if not is_launcher:
        return f"""        <activity
            android:name="{activity.qualified_name}"
            android:exported="false"
            android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />"""

    return f"""        <activity
            android:name="{activity.qualified_name}"
            android:exported="true"
            android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="text/*" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:mimeType="text/csv" />
                <data android:mimeType="text/comma-separated-values" />
                <data android:mimeType="text/plain" />
            </intent-filter>
        </activity>"""


def _sync_kotlin_sources(java_source_root: Path, files: List[KotlinSourceFile]) -> _SourceSyncReport:
    java_source_root.mkdir(parents=True, exist_ok=True)

    expected_targets = set()
    updated_file_names: List[str] = []
    unchanged_count = 0

    for file in files:
        target_path = (java_source_root / Path(*file.package_name.split(".")) / file.source_path.name).resolve()
        expected_targets.add(target_path)
        if _write_file_if_changed(target_path, file.code):
            updated_file_names.append(file.source_path.name)
        else:
            unchanged_count += 1

    return _SourceSyncReport(
        updated_file_names=updated_file_names,
        unchanged_count=unchanged_count,
        removed_file_names=_remove_stale_kotlin_files(java_source_root, expected_targets)
    )


def _remove_stale_kotlin_files(java_source_root: Path, expected_targets: set) -> List[str]:
    removed_file_names: List[str] = []
    for existing_file in list(java_source_root.rglob("*.kt")):
        if existing_file.resolve() not in expected_targets:
            removed_file_names.append(existing_file.name)
            existing_file.unlink()
    return removed_file_names


def _print_sync_report(report: _SourceSyncReport) -> None:
    updated_detail = f" ({', '.join(report.updated_file_names)})" if report.updated_file_names else ""
    removed_detail = f", {len(report.removed_file_names)} stale removed" if report.removed_file_names else ""
    print(
        f"[*] Source sync complete: {len(report.updated_file_names)} copied/updated{updated_detail}, "
        f"{report.unchanged_count} unchanged{removed_detail}."
    )


def _write_file_if_changed(path: Path, content: str, is_executable: bool = False) -> bool:
    normalized_content = content.lstrip("\n")
    path.parent.mkdir(parents=True, exist_ok=True)

    has_changed = not _file_contains(path, normalized_content)
    if has_changed:
        path.write_text(normalized_content, encoding="utf-8")

    if is_executable and not _is_windows():
        _make_executable(path)

    return has_changed


def _file_contains(path: Path, expected_content: str) -> bool:
    if not path.exists():
        return False
    try:
        return path.read_text(encoding="utf-8") == expected_content
    except Exception:
        return False


def _make_executable(path: Path) -> None:
    current_mode = path.stat().st_mode
    if not current_mode & stat.S_IXUSR:
        path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)


def _bootstrap_gradle_wrapper(project_dir: Path) -> None:
    project_dir = project_dir.resolve()
    wrapper_dir = project_dir / "gradle" / "wrapper"
    wrapper_dir.mkdir(parents=True, exist_ok=True)

    _write_file_if_changed(wrapper_dir / "gradle-wrapper.properties", GRADLE_WRAPPER_PROPERTIES)
    _write_file_if_changed(project_dir / "gradlew", GRADLEW_POSIX_SCRIPT, is_executable=True)
    _write_file_if_changed(project_dir / "gradlew.bat", GRADLEW_WINDOWS_SCRIPT)

    jar_path = wrapper_dir / "gradle-wrapper.jar"
    if jar_path.exists() and jar_path.stat().st_size > 0:
        return

    print(f"[*] Downloading gradle-wrapper.jar from {GRADLE_WRAPPER_JAR_URL} ...")
    if not _download_file(GRADLE_WRAPPER_JAR_URL, jar_path):
        print("    Run 'gradle wrapper' inside the generated project directory if needed.")


def _download_file(url: str, destination: Path) -> bool:
    try:
        urllib.request.urlretrieve(url, str(destination))
        return True
    except Exception as download_error:
        print(f"[!] Python download failed ({download_error}); retrying with curl...")

    if _download_with_curl(url, destination):
        return True

    destination.unlink(missing_ok=True)
    print(f"[!] Warning: Could not download {url} automatically.")
    return False


def _download_with_curl(url: str, destination: Path) -> bool:
    curl_path = shutil.which("curl")
    if not curl_path:
        return False
    result = subprocess.run([curl_path, "-fsSL", "-o", str(destination), url], check=False)
    return result.returncode == 0 and destination.exists() and destination.stat().st_size > 0


def _connected_device_serials(toolchain: AndroidToolchain) -> List[str]:
    try:
        result = subprocess.run(
            [toolchain.adb, "devices"],
            capture_output=True,
            text=True,
            check=True
        )
    except Exception:
        return []

    device_serials = []
    for line in result.stdout.splitlines()[ADB_HEADER_LINE_COUNT:]:
        columns = line.strip().split()
        if len(columns) >= 2 and columns[1] == ADB_READY_STATE:
            device_serials.append(columns[0])
    return device_serials


def _choose_avd(avd_names: List[str], preferred_avd: Optional[str]) -> str:
    if not preferred_avd or preferred_avd in avd_names:
        return preferred_avd or avd_names[0]

    fallback_avd = avd_names[0]
    print(f"[!] Requested AVD '{preferred_avd}' not found. Available: {', '.join(avd_names)}")
    print(f"[*] Falling back to '{fallback_avd}'...")
    return fallback_avd


def _launch_emulator(toolchain: AndroidToolchain, avd_name: str) -> None:
    print(f"[*] Launching Android Emulator '{avd_name}'...")
    subprocess.Popen(
        [toolchain.emulator, "-avd", avd_name, "-netdelay", "none", "-netspeed", "full"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )

    print("[*] Waiting for emulator to connect to ADB...")
    subprocess.run([toolchain.adb, "wait-for-device"], check=False)


def _wait_for_boot_completion(toolchain: AndroidToolchain) -> bool:
    print("[*] Waiting for Android OS boot to complete...", end="", flush=True)
    for _ in range(BOOT_POLL_ATTEMPTS):
        if _is_boot_completed(toolchain):
            print(" Ready!")
            return True
        print(".", end="", flush=True)
        time.sleep(BOOT_POLL_INTERVAL_SECONDS)

    print("\n[!] Timed out waiting for emulator boot completion.")
    return False


def _is_boot_completed(toolchain: AndroidToolchain) -> bool:
    result = subprocess.run(
        [toolchain.adb, "shell", "getprop", "sys.boot_completed"],
        capture_output=True,
        text=True
    )
    return result.stdout.strip() == BOOT_COMPLETED_VALUE


def _export_apk(built_apk: Path, apk_export_dir: Path) -> Path:
    apk_export_dir.mkdir(parents=True, exist_ok=True)
    exported_apk = apk_export_dir / built_apk.name
    shutil.copy2(built_apk, exported_apk)
    print(f"[+] APK copied to: {exported_apk}")
    return exported_apk


def _print_command_reference(
    project_dir: Path,
    toolchain: AndroidToolchain,
    project: KotlinProject,
    script_invocation: str
) -> None:
    gradlew_command = ".\\gradlew.bat" if _is_windows() else "./gradlew"
    avd_names = list_installed_avds(toolchain)
    sample_avd = avd_names[0] if avd_names else "<YOUR_AVD_NAME>"
    debug_apk_path = os.path.join("app", "build", "outputs", "apk", "debug", "app-debug.apk")
    sdk_description = toolchain.sdk_path if toolchain.sdk_path else "Not auto-detected (Set ANDROID_HOME)"
    heavy_rule = "=" * COMMAND_REFERENCE_WIDTH

    print("\n" + heavy_rule)
    print("  GENERATED ANDROID PROJECT — QUICK COMMAND REFERENCE")
    print(heavy_rule)
    print(f"  Project Directory : {project_dir.resolve()}")
    print(f"  Android SDK       : {sdk_description}")
    print(f"  Detected AVDs     : {', '.join(avd_names) if avd_names else 'None detected'}")
    print("-" * COMMAND_REFERENCE_WIDTH)

    print("\n1. LIST & START AN INSTALLED ANDROID EMULATOR:")
    print(f"   {toolchain.emulator} -list-avds")
    print(f"   {toolchain.emulator} -avd {sample_avd} &")
    print(f"   {toolchain.adb} wait-for-device")

    print("\n2. BUILD THE APK (DEBUG OR RELEASE):")
    print(f"   cd {project_dir}")
    print(f"   {gradlew_command} assembleDebug")
    print(f"   # Output APK -> {project_dir / debug_apk_path}")

    print("\n3. INSTALL & LAUNCH THE APP ON THE RUNNING EMULATOR:")
    print(f"   cd {project_dir}")
    print(f"   {gradlew_command} installDebug")
    print(f"   {toolchain.adb} shell am start -n {project.launch_component}")

    print("\n4. OR LET THIS SCRIPT DO IT ALL AUTOMATICALLY:")
    print(f"   {script_invocation} --build-apk")
    print(f"   {script_invocation} --start-emulator")
    print(f"   {script_invocation} --run")
    print(f"   {script_invocation} --watch")
    print(heavy_rule + "\n")


if __name__ == "__main__":
    main()
