package com.foxx.androidcast.ota;

/*********************************************************************
 * OtaBundleInstaller.java
 * Created at: Wed 20 May 2026 15:17:13 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class OtaBundleInstaller
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (1 commits, 181 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	050709074c45c65d229157f78c7b52d9d6be9fbfb9af82d4a862d986aa3760c5
**********************************************************************/
import android.content.Context;
import android.util.Log;

import com.foxx.androidcast.util.IoUtils;

import org.json.JSONObject;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
 * Downloads an optional {@code .otabundle.zip} (STORE entries: manifest.json, sign.json,
 * package.apk) and returns a verified local APK file.
 */
public final class OtaBundleInstaller {
    private static final String TAG = "OtaBundleInstaller";
    static final String ENTRY_MANIFEST = "manifest.json";
    static final String ENTRY_SIGN = "sign.json";
    static final String ENTRY_PACKAGE = "package.apk";

    private static final int CONNECT_TIMEOUT_MS = 15_000;
    private static final int READ_TIMEOUT_MS = 300_000;
    private static final int MAX_BUNDLE_BYTES = 512 * 1024 * 1024;

    private OtaBundleInstaller() {}

    public static File downloadApkFromBundle(Context context, OtaManifest manifest,
            OtaApkInstaller.ProgressListener listener) throws Exception {
        if (manifest == null || !manifest.hasHttpBundle()) {
            throw new IllegalArgumentException("Missing HTTP bundleUrl");
        }
        File cacheRoot = new File(context.getCacheDir(), "ota");
        if (!cacheRoot.exists() && !cacheRoot.mkdirs()) {
            throw new IllegalStateException("Cannot create OTA cache dir");
        }
        int version = manifest.effectiveVersionCode();
        File bundleZip = new File(cacheRoot, "bundle-" + version + ".otabundle.zip");
        File extractDir = new File(cacheRoot, "bundle-" + version);
        deleteRecursive(extractDir);
        if (bundleZip.exists() && !bundleZip.delete()) {
            throw new IllegalStateException("Cannot replace cached bundle");
        }
        downloadBundleZip(manifest, bundleZip, listener);
        if (!manifest.bundleSha256Hex.isEmpty()) {
            String actual = OtaApkInstaller.sha256Hex(bundleZip);
            if (!manifest.bundleSha256Hex.equalsIgnoreCase(actual)) {
                bundleZip.delete();
                throw new IllegalStateException("Bundle checksum mismatch");
            }
        }
        if (!extractDir.mkdirs()) {
            throw new IllegalStateException("Cannot create bundle extract dir");
        }
        unzipBundle(bundleZip, extractDir);
        File apk = new File(extractDir, ENTRY_PACKAGE);
        if (!apk.isFile()) {
            throw new IllegalStateException("Bundle missing " + ENTRY_PACKAGE);
        }
        String expectedSha = manifest.sha256Hex;
        if (expectedSha.isEmpty()) {
            File signFile = new File(extractDir, ENTRY_SIGN);
            if (signFile.isFile()) {
                try (FileInputStream fis = new FileInputStream(signFile)) {
                    byte[] buf = IoUtils.readAllBytes(fis);
                    OtaSignBundle sign = OtaSignBundle.fromJson(
                            new JSONObject(new String(buf, StandardCharsets.UTF_8)));
                    expectedSha = sign.sha256Hex;
                }
            }
        }
        if (!expectedSha.isEmpty()) {
            String actualApk = OtaApkInstaller.sha256Hex(apk);
            if (!expectedSha.equalsIgnoreCase(actualApk)) {
                deleteRecursive(extractDir);
                throw new IllegalStateException("APK checksum mismatch (bundle)");
            }
        }
        Log.i(TAG, "Extracted bundle APK " + apk.length() + " bytes");
        return apk;
    }

    private static void downloadBundleZip(OtaManifest manifest, File out,
            OtaApkInstaller.ProgressListener listener) throws Exception {
        HttpURLConnection conn = (HttpURLConnection) new URL(manifest.bundleUrl).openConnection();
        conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
        conn.setReadTimeout(READ_TIMEOUT_MS);
        conn.setInstanceFollowRedirects(true);
        int code = conn.getResponseCode();
        if (code < 200 || code >= 300) {
            conn.disconnect();
            throw new IllegalStateException("Bundle download HTTP " + code);
        }
        long total = manifest.bundleSizeBytes > 0L
                ? manifest.bundleSizeBytes
                : conn.getContentLengthLong();
        if (total > MAX_BUNDLE_BYTES) {
            conn.disconnect();
            throw new IllegalStateException("Bundle too large");
        }
        try (InputStream in = new BufferedInputStream(conn.getInputStream());
                FileOutputStream fos = new FileOutputStream(out)) {
            byte[] buf = new byte[32 * 1024];
            long downloaded = 0;
            int n;
            while ((n = in.read(buf)) != -1) {
                downloaded += n;
                if (downloaded > MAX_BUNDLE_BYTES) {
                    throw new IllegalStateException("Bundle exceeds size limit");
                }
                fos.write(buf, 0, n);
                if (listener != null) {
                    listener.onProgress(downloaded, total > 0 ? total : downloaded);
                }
            }
        } finally {
            conn.disconnect();
        }
        Log.i(TAG, "Downloaded bundle " + out.length() + " bytes");
    }

    static void unzipBundle(File zipFile, File destDir) throws Exception {
        try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(
                new FileInputStream(zipFile)))) {
            ZipEntry entry;
            byte[] buf = new byte[8192];
            while ((entry = zis.getNextEntry()) != null) {
                if (entry.isDirectory()) {
                    continue;
                }
                String name = entry.getName();
                int slash = name.lastIndexOf('/');
                if (slash >= 0) {
                    name = name.substring(slash + 1);
                }
                if (!ENTRY_MANIFEST.equals(name) && !ENTRY_SIGN.equals(name)
                        && !ENTRY_PACKAGE.equals(name)) {
                    continue;
                }
                File out = new File(destDir, name);
                try (BufferedOutputStream bos = new BufferedOutputStream(
                        new FileOutputStream(out))) {
                    int n;
                    while ((n = zis.read(buf)) != -1) {
                        bos.write(buf, 0, n);
                    }
                }
                zis.closeEntry();
            }
        }
    }

    static void deleteRecursive(File file) {
        if (file == null || !file.exists()) {
            return;
        }
        if (file.isDirectory()) {
            File[] children = file.listFiles();
            if (children != null) {
                for (File child : children) {
                    deleteRecursive(child);
                }
            }
        }
        file.delete();
    }
}
