package chat.vojo.app;

import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.Log;

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * Fetches and decodes avatar bitmaps from MXC URLs, populating
 * {@link AvatarBitmapCache}.
 *
 * URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
 *   mxc://server/mediaId
 *     → <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
 *       ?width=96&height=96&method=crop
 *     + Authorization: Bearer <accessToken>
 *
 * The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is
 * NOT used — every Synapse the Vojo audience runs against (vanilla, v1.11+
 * by deployment policy, see docs/ai/server-side.md) speaks auth media.
 * Removing the legacy fallback keeps the loader off the deprecated path
 * and avoids leaking the access token to a server route that doesn't
 * require it.
 *
 * Concurrency: each MXC URL is fetched at most once concurrently — the
 * `inFlight` set short-circuits duplicate requests from rapid
 * append-rebuild cycles on the same conversation. Loads happen on a
 * shared 4-thread pool; bigger than 1 so 5 senders in a group chat can
 * load in parallel, capped to keep socket pressure under the typical
 * mobile network budget.
 *
 * Two entry points:
 *   - {@link #loadAllWithTimeout}: synchronous wait, used by the render
 *     path to populate the cache before building the MessagingStyle so the
 *     first post already has avatars. Timeout-bounded to keep FCM thread
 *     responsive (Android budgets ~10s; we use 800 ms).
 *   - {@link #prefetch}: fire-and-forget, used for warm-up scenarios.
 *     Not currently called but kept for the room-metadata bridge to
 *     eventually warm the cache on visibility resume.
 */
final class AvatarLoader {

    private static final String TAG = "AvatarLoader";

    private static final int AVATAR_SIZE_PX = 96;
    private static final int CONNECT_TIMEOUT_MS = 5_000;
    private static final int READ_TIMEOUT_MS = 5_000;
    private static final int RENDER_BLOCK_TIMEOUT_MS = 800;
    // Cap decoded bitmap byte count — a malicious / huge avatar shouldn't
    // OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to
    // 4× that (~140 KB) to allow some downscaling slack on servers that
    // return slightly oversized thumbnails.
    private static final int MAX_DECODED_BYTES = 144 * 1024;

    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);

    // MXC URL → CountDownLatch that fires when the in-flight download
    // completes (success or failure). A second caller observing an
    // already-pending mxc waits on the SAME latch instead of either
    // returning empty-handed or kicking off a duplicate fetch. Latches
    // are removed by the worker task in its finally block; the same task
    // that put the entry is the only one allowed to remove it, so a slow
    // remove() race is harmless.
    private static final ConcurrentHashMap<String, CountDownLatch> inFlight =
        new ConcurrentHashMap<>();

    private AvatarLoader() {}

    /**
     * Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while
     * fetching any of the given MXC URLs that are not yet in
     * {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight
     * URLs are awaited via the shared latch — duplicate concurrent
     * fetches do not happen.
     *
     * Designed to be called inline from the render path: after this
     * returns, {@link AvatarBitmapCache#get} will be non-null for every
     * MXC that loaded successfully within the budget. Failures are
     * silent — the render then falls back to a Person without icon
     * (Android renders initials/blank).
     *
     * Returns the count of avatars that landed in the cache during this
     * call (purely informational — useful for logs).
     */
    static int loadAllWithTimeout(Context ctx, Collection<String> mxcs) {
        if (mxcs == null || mxcs.isEmpty()) {
            Log.i(TAG, "loadAll: empty input, skip");
            return 0;
        }
        SharedPreferences prefs = ctx.getSharedPreferences(
            VojoPollWorker.PREFS, Context.MODE_PRIVATE);
        String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
        String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
        if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
            // No credentials yet (fresh install + first push). We can't
            // resolve MXC URLs without an access token. Falling back to
            // no-icon Person renderer is the correct behaviour here.
            Log.i(TAG, "loadAll: no credentials in prefs, skip"
                + " hasToken=" + (token != null && !token.isEmpty())
                + " hasHs=" + (homeserver != null && !homeserver.isEmpty()));
            return 0;
        }
        // De-duplicate and filter to misses only; if the cache already has
        // an entry, no work is needed.
        Set<String> toLoad = new LinkedHashSet<>();
        for (String mxc : mxcs) {
            if (mxc == null || mxc.isEmpty()) continue;
            if (!mxc.startsWith("mxc://")) continue;
            if (AvatarBitmapCache.get(mxc) != null) continue;
            toLoad.add(mxc);
        }
        if (toLoad.isEmpty()) return 0;

        // Per-mxc latches shared across concurrent callers — a second
        // caller arriving while we're already mid-fetch waits on the
        // SAME latch instead of forcing a duplicate HTTP or returning
        // immediately empty-handed (which was the previous bug — see
        // git blame for the race description).
        java.util.List<CountDownLatch> waits = new java.util.ArrayList<>(toLoad.size());
        for (String mxc : toLoad) {
            CountDownLatch myLatch = new CountDownLatch(1);
            CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch);
            if (existing != null) {
                // Already in flight — share the original latch.
                waits.add(existing);
                continue;
            }
            // We own this fetch; kick off the worker that will fire
            // myLatch when done.
            waits.add(myLatch);
            final String capturedMxc = mxc;
            final String capturedHomeserver = homeserver;
            final String capturedToken = token;
            EXECUTOR.execute(() -> {
                try {
                    Bitmap bmp = fetchAndDecode(ctx, capturedMxc, capturedHomeserver, capturedToken);
                    if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
                } catch (Throwable t) {
                    Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
                } finally {
                    // Remove BEFORE countDown so a freshly-arriving caller
                    // doesn't observe a stale latch for an already-loaded
                    // mxc (would block until the next call with no fetch
                    // actually pending). Cache.get() on the post-await
                    // side covers the race where remove+put-cache happens
                    // between two latch waits.
                    inFlight.remove(capturedMxc);
                    myLatch.countDown();
                }
            });
        }
        // Single budget for the whole batch — wait for all latches OR
        // hit the timeout. Latches that fire early just return await()
        // immediately; the slowest one consumes the remainder of the
        // budget.
        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS);
        try {
            for (CountDownLatch latch : waits) {
                long remaining = deadline - System.nanoTime();
                if (remaining <= 0) break;
                latch.await(remaining, TimeUnit.NANOSECONDS);
            }
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
        // Count how many actually landed in the cache during this call —
        // includes both items we fetched and items that finished after our
        // timeout (which won't be reflected in this count but are still
        // usable on the next render).
        int hits = 0;
        for (String mxc : toLoad) {
            if (AvatarBitmapCache.get(mxc) != null) hits += 1;
        }
        Log.i(TAG, "loadAll: requested=" + mxcs.size()
            + " toLoad=" + toLoad.size() + " hits=" + hits);
        return hits;
    }

    /**
     * Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the
     * authenticated v1.11+ media endpoint and decode the response into a
     * Bitmap. Returns null on any non-2xx, decode failure, or oversized
     * payload (see {@link #MAX_DECODED_BYTES}).
     */
    private static Bitmap fetchAndDecode(Context ctx, String mxc, String homeserver, String token)
        throws IOException {
        Parsed parsed = parseMxc(mxc);
        if (parsed == null) {
            Log.w(TAG, "fetch: malformed mxc=" + mxc);
            return null;
        }

        // Server + mediaId are NOT URL-encoded — matches matrix-js-sdk's
        // content-repo.ts (it concatenates verbatim via `new URL()`).
        // URLEncoder would turn `example.com:8448` into `example.com%3A8448`,
        // which Synapse's media router rejects as an unknown server.
        // mediaId is base64-ish per spec (URL-safe alphabet) so no
        // encoding is needed there either.
        StringBuilder url = new StringBuilder(homeserver);
        if (!homeserver.endsWith("/")) url.append('/');
        url.append("_matrix/client/v1/media/thumbnail/")
            .append(parsed.server)
            .append('/')
            .append(parsed.mediaId)
            .append("?width=").append(AVATAR_SIZE_PX)
            .append("&height=").append(AVATAR_SIZE_PX)
            .append("&method=crop");

        // Route through the loopback relay when a proxy is enabled (§15 #3).
        HttpURLConnection conn = ProxyRouting.openConnection(ctx, new URL(url.toString()));
        try {
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Authorization", "Bearer " + token);
            conn.setRequestProperty("Accept", "image/*");
            conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
            conn.setReadTimeout(READ_TIMEOUT_MS);
            int code = conn.getResponseCode();
            Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code);
            if (code < 200 || code >= 300) return null;
            int contentLength = conn.getContentLength();
            if (contentLength > MAX_DECODED_BYTES) {
                Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc);
                return null;
            }
            try (InputStream in = conn.getInputStream()) {
                BitmapFactory.Options opts = new BitmapFactory.Options();
                // Stick with ARGB_8888 even on low-mem devices — RGB_565
                // would lose alpha (group avatars often have a
                // transparent corner) and the cache cap (4 MB) already
                // bounds total memory. inJustDecodeBounds + sample-size
                // dance is overkill at 96×96.
                opts.inPreferredConfig = Bitmap.Config.ARGB_8888;
                Bitmap bmp = BitmapFactory.decodeStream(in, null, opts);
                if (bmp == null) {
                    Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc);
                    return null;
                }
                if (bmp.getByteCount() > MAX_DECODED_BYTES) {
                    Log.w(TAG, "fetch: decoded oversized "
                        + bmp.getByteCount() + " bytes mxc=" + mxc);
                    bmp.recycle();
                    return null;
                }
                // Crop into a circle BEFORE caching — IconCompat.createWithBitmap
                // renders the bitmap verbatim, with no shape mask, so a
                // square thumbnail from the homeserver lands as a square
                // tile in the shade (visible on Android 12+ where
                // conversation Person icons used to be auto-rounded by the
                // OS — this changed). Pre-cropping guarantees a round
                // visual on every API level instead of relying on the
                // SystemUI of the day. The original square bitmap is
                // recycled once the circular copy is in hand.
                return toCircularBitmap(bmp);
            }
        } finally {
            conn.disconnect();
        }
    }

    /**
     * Re-encode a circular avatar as an adaptive-icon-shaped bitmap:
     * embeds the avatar inside a transparent canvas whose total size is
     * 1.5× the avatar so Android's adaptive-icon safe zone (66% of total)
     * covers the entire avatar without clipping.
     *
     * Required for conversation-shortcut icons per docs at
     * developer.android.com/develop/ui/views/notifications/conversations:
     * *"To avoid unintentional clipping of your shortcut avatar, provide
     * an AdaptiveIconDrawable for the shortcut's icon."*
     *
     * Without this padding, IconCompat.createWithAdaptiveBitmap would
     * crop ~17% off every edge of the avatar to fit the safe zone — a
     * visible mutilation. With it, the shortcut icon renders pixel-
     * identical to the circular avatar inside the system shade's
     * conversation slot.
     */
    static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) {
        int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight());
        // Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize)
        // covers the full avatar. Rounded up to keep the canvas even.
        int canvasSize = (int) Math.ceil(avatarSize / 0.66f);
        if (canvasSize % 2 != 0) canvasSize += 1;
        Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(output);
        int offset = (canvasSize - avatarSize) / 2;
        canvas.drawBitmap(circularAvatar, offset, offset, null);
        return output;
    }

    /**
     * Return a circular ARGB_8888 bitmap of the source — centre-cropped to
     * a square if non-square, then masked with a circular path so the
     * corners are transparent. The source bitmap is recycled.
     *
     * Anti-aliased edges via Paint.setAntiAlias on the circle draw — the
     * BitmapShader copies the source's pixels into the circular region in
     * a single drawCircle call, which keeps allocation to one output
     * bitmap (vs the naive "decode → square crop → mask compose" path
     * that touches three intermediate bitmaps).
     */
    private static Bitmap toCircularBitmap(Bitmap source) {
        int size = Math.min(source.getWidth(), source.getHeight());
        Bitmap squareSource;
        if (source.getWidth() == size && source.getHeight() == size) {
            squareSource = source;
        } else {
            int x = (source.getWidth() - size) / 2;
            int y = (source.getHeight() - size) / 2;
            squareSource = Bitmap.createBitmap(source, x, y, size, size);
            source.recycle();
        }
        Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(output);
        Paint paint = new Paint();
        paint.setAntiAlias(true);
        paint.setShader(new BitmapShader(
            squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
        float radius = size / 2f;
        canvas.drawCircle(radius, radius, radius, paint);
        if (squareSource != source) {
            squareSource.recycle();
        }
        return output;
    }

    private static final class Parsed {
        final String server;
        final String mediaId;

        Parsed(String server, String mediaId) {
            this.server = server;
            this.mediaId = mediaId;
        }
    }

    /**
     * Split an `mxc://server/mediaId` URL into its two components. Returns
     * null on any malformed input — caller drops the avatar silently.
     */
    private static Parsed parseMxc(String mxc) {
        if (mxc == null) return null;
        final String prefix = "mxc://";
        if (!mxc.startsWith(prefix)) return null;
        int slash = mxc.indexOf('/', prefix.length());
        if (slash < 0 || slash == prefix.length()) return null;
        String server = mxc.substring(prefix.length(), slash);
        String mediaId = mxc.substring(slash + 1);
        if (server.isEmpty() || mediaId.isEmpty()) return null;
        return new Parsed(server, mediaId);
    }
}
