"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update).

Workflow:
  1. `npx expo export --platform android` -> produkuje `mobile/dist/`
  2. Czyta `dist/metadata.json` (file->hash mapping wygenerowany przez Expo)
  3. Buduje manifest JSON wg Expo Updates v1 protocol
  4. scp dist/ + manifest.json -> `/root/goon/app/static/expo-updates/<runtime>/<update_id>/`
  5. Updatuje `current.json` żeby wskazać nowy update_id
  6. Mobile przy następnym launchu zauważy zmianę manifestu i pobierze bundle

UWAGA: runtimeVersion MUSI pasować do `EXPO_RUNTIME_VERSION` z AndroidManifest
embedowanego APK. Aktualnie `1.0`. Bumpować TYLKO gdy native code change wymusza
incompat z poprzednim bundle (wtedy nowy APK trzeba przez PackageInstaller).
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import mimetypes
import os
import subprocess
import sys
import uuid

# Windows console domyślnie cp1252 → polskie znaki w finalnych printach rzucały
# UnicodeEncodeError PO udanym publishu (exit 1 mylił "czy się udało"). Wymuś UTF-8.
if hasattr(sys.stdout, "reconfigure"):
    try:
        sys.stdout.reconfigure(encoding="utf-8")
        sys.stderr.reconfigure(encoding="utf-8")
    except Exception:
        pass
from datetime import UTC, datetime
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.1"  # == EXPO_RUNTIME_VERSION w APK (bump 1.0→1.1 2026-06; aktywny kanał + app.json runtimeVersion=1.1).
# Operator config — set in your shell / .env.local before running this script.
# Defaults are placeholders intended to fail loudly if you forgot to configure.
VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com")
VPS_BASE = os.environ.get("GOON_VPS_UPDATES_DIR", "/root/goon/app/static/expo-updates")
PUBLIC_BASE = os.environ.get(
    "GOON_PUBLIC_UPDATES_URL", "https://your-vps.example.com:8443/expo-updates/asset"
)

# git-bash (MSYS) na Windows konwertuje POSIX-owe ścieżki w env (`/root/...`) na
# `C:/Program Files/Git/root/...` ZANIM python je zobaczy → upload trafiał w złą ścieżkę
# (bug 2026-06-02). Odkręcamy częsty przypadek. Alternatywnie: uruchom z MSYS_NO_PATHCONV=1.
if "/Git/root/" in VPS_BASE:
    VPS_BASE = VPS_BASE[VPS_BASE.index("/Git") + len("/Git"):]


def sha256_b64url(data: bytes) -> str:
    """SHA-256 -> base64url bez padding (Expo Updates wymaga tego formatu)."""
    return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()


def run(cmd: list[str], cwd: Path | None = None) -> None:
    print(f"$ {' '.join(cmd)}")
    # Windows: `npx` to `.cmd` więc subprocess wymaga `shell=True` (CreateProcess
    # nie wie jak rozwiązać .cmd extension bez shella). Linux/Mac: shell=False
    # jest bezpieczniejsze (brak shell interpolation), ale my podajemy list[str]
    # więc i tak nie ma shell injection risk.
    shell = sys.platform == "win32" and cmd[0] in ("npx", "npm", "scp", "ssh")
    # MSYS_NO_PATHCONV=1 — blokuje konwersję `/root/...` argów ssh/scp przez git-bash sh.
    env = {**os.environ, "MSYS_NO_PATHCONV": "1"}
    if shell:
        subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env)
    else:
        subprocess.run(cmd, cwd=cwd, check=True, env=env)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
    ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
    args = ap.parse_args()

    if not args.skip_export:
        if DIST.exists():
            import shutil
            shutil.rmtree(DIST)
        run(["npx", "expo", "export", "--platform", "android"], cwd=MOBILE)

    metadata_path = DIST / "metadata.json"
    if not metadata_path.exists():
        print(f"ERROR: {metadata_path} not found — czy expo export się powiodło?")
        return 1
    metadata = json.loads(metadata_path.read_text(encoding="utf-8"))

    android_meta = metadata["fileMetadata"]["android"]
    bundle_rel = android_meta["bundle"]
    bundle_path = DIST / bundle_rel
    bundle_data = bundle_path.read_bytes()
    bundle_hash = sha256_b64url(bundle_data)

    update_id = str(uuid.uuid4())
    created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")

    def asset_url(rel_path: str) -> str:
        # rel_path = "_expo/static/js/android/abc.hbc" lub "assets/abc".
        # Windows: Expo metadata.json używa os.sep (`\`) w assets[].path. Normalizujemy
        # do `/` żeby URL był poprawny path-side (Linux backend nie traktuje `\` jako
        # separatora — bez tego mobile dostaje 404 na każdy asset i odrzuca update).
        rel_path = rel_path.replace("\\", "/")
        return f"{PUBLIC_BASE}?asset={update_id}/{rel_path}&runtimeVersion={args.runtime}&platform=android"

    launch_asset = {
        "hash": bundle_hash,
        "key": Path(bundle_rel).stem,
        "contentType": "application/javascript",
        "url": asset_url(bundle_rel),
    }

    assets: list[dict] = []
    for a in android_meta.get("assets", []):
        ap_path = DIST / a["path"]
        ap_data = ap_path.read_bytes()
        ext = a.get("ext") or ""
        if not ext.startswith("."):
            ext = "." + ext if ext else ""
        ctype = mimetypes.types_map.get(ext, "application/octet-stream")
        assets.append({
            "hash": sha256_b64url(ap_data),
            "key": Path(a["path"]).name,
            "contentType": ctype,
            "fileExtension": ext,
            "url": asset_url(a["path"]),
        })

    # `extra.expoClient` musi zawierać zawartość app.json (sekcja `expo`) — bez tego
    # `Constants.expoConfig` w mobile zwraca null po reload OTA, więc np. version
    # czyta się jako '0.0.0' (zgłoszone w bug-report 97adff93 — fake "Update available"
    # dialog 0.1.8 vs 0.0.0). Patrz `mobile/src/lib/appVersion.ts`.
    app_json_path = MOBILE / "app.json"
    expo_client_config: dict = {}
    if app_json_path.exists():
        app_json = json.loads(app_json_path.read_text(encoding="utf-8"))
        expo_client_config = app_json.get("expo", {})

    manifest = {
        "id": update_id,
        "createdAt": created_at,
        "runtimeVersion": args.runtime,
        "launchAsset": launch_asset,
        "assets": assets,
        "metadata": {},
        "extra": {
            "expoClient": expo_client_config,
        },
    }

    manifest_local = DIST / "manifest.json"
    manifest_local.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    print(f"Manifest: {manifest_local}")
    print(f"  update_id: {update_id}")
    print(f"  assets: {len(assets)}")
    print(f"  bundle: {bundle_rel} ({len(bundle_data) // 1024} KB)")

    # Upload do VPS
    remote_dir = f"{VPS_BASE}/{args.runtime}/{update_id}"
    print(f"\nUpload -> {remote_dir}")
    run(["ssh", VPS, f"mkdir -p {remote_dir}"])
    # Źródło jako "." z cwd=DIST — Windows scp traktuje `C:\...` jako `host:path`
    # (litera dysku = host), więc nie podajemy ścieżki z literą dysku (bug 2026-06-02).
    run(["scp", "-r", ".", f"{VPS}:{remote_dir}/"], cwd=DIST)

    # current.json wskazuje na nowy update_id
    current = {
        "update_id": update_id,
        "runtime_version": args.runtime,
        "created_at": created_at,
    }
    current_local = DIST / "current.json"
    current_local.write_text(json.dumps(current, indent=2), encoding="utf-8")
    run([
        "scp",
        "current.json",
        f"{VPS}:{VPS_BASE}/{args.runtime}/current.json",
    ], cwd=DIST)

    print(f"\nOK Update {update_id} live na runtime {args.runtime}")
    print(f"  Klienci dostaną bundle przy następnym launchu (lub Updates.checkForUpdate()).")
    return 0


if __name__ == "__main__":
    sys.exit(main())
