package wang.yaojia.webterm.wiring

import android.graphics.Bitmap
import android.util.LruCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import java.util.UUID

/**
 * Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
 *
 * A read-only [ApiClient] preview fetch (`GET /live-sessions/:id/preview`) over the ONE shared mTLS
 * OkHttp client is fed into an off-screen [com.termux.terminal.TerminalEmulator] (no `TerminalView`, no
 * subprocess) and rasterised cell-by-cell onto a `Bitmap` — see [CanvasThumbnailRasterizer]. This class
 * owns the *policy* around that expensive work; the actual `Canvas`/`Bitmap` draw is delegated to the
 * [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays
 * JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device).
 *
 * ### The four policies (plan §6.7)
 *  - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's
 *    last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we
 *    return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache]
 *    seam so the "unchanged ⇒ no re-render" invariant is testable against a plain-map fake.
 *  - **Concurrency cap** — a fair (FIFO) [Semaphore] with 2 permits bounds the fetch+render fan-out so a
 *    grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations.
 *  - **In-flight dedup** — a `Map<Key, Deferred<Bitmap>>` under a [Mutex]: a second request for a key
 *    already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render.
 *  - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the
 *    same key, so a broken/oversized session is not retried in a hot scroll loop.
 *
 * The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls
 * a card off-screen and cancels its collect never cancels a shared render other cards still await.
 */
public class ThumbnailPipeline(
    private val previewSource: PreviewSource,
    private val rasterizer: ThumbnailRasterizer,
    private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
    dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
    maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
) {
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)

    /** Fair (FIFO) permit gate around the fetch+render of ONE thumbnail (plan §6.7). */
    private val renderGate = Semaphore(maxConcurrent)

    /** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
    private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap>>()
    private val inFlightMutex = Mutex()

    /**
     * The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
     * `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
     * caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
     * returned instead (only cooperative cancellation of [scope] propagates).
     */
    public suspend fun thumbnail(session: LiveSessionInfo): Bitmap {
        val key = ThumbnailKey(session.id, session.lastOutputAt)
        // Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
        cache.get(key)?.let { return it }

        val deferred = inFlightMutex.withLock {
            // Re-check under the lock: a render that finished between the fast path and here has already
            // populated the cache and removed its in-flight entry.
            cache.get(key)?.let { return it }
            inFlight[key] ?: startRender(key).also { inFlight[key] = it }
        }
        return deferred.await()
    }

    /** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
    public fun close() {
        scope.cancel()
    }

    private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
        val bitmap = renderGuarded(key.sessionId)
        // Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
        // key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
        inFlightMutex.withLock {
            cache.put(key, bitmap)
            inFlight.remove(key)
        }
        bitmap
    }

    private suspend fun renderGuarded(sessionId: UUID): Bitmap =
        try {
            renderGate.withPermit {
                val preview = previewSource.fetch(sessionId)
                rasterizer.rasterize(preview)
            }
        } catch (e: CancellationException) {
            throw e // never swallow cooperative cancellation
        } catch (t: Throwable) {
            // Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
            // so the broken session isn't retried on every scroll frame (§6.7).
            rasterizer.placeholder()
        }

    public companion object {
        /** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
        public const val MAX_CONCURRENT_RENDERS: Int = 2

        /** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
        public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
    }
}

/**
 * Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is
 * unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null`
 * [lastOutputAt] (pre-P1 server that omits it) is a stable key too, so such a session renders once and is
 * then served from cache until its id changes.
 */
public data class ThumbnailKey(val sessionId: UUID, val lastOutputAt: Long?)

/** Fetches the read-only preview bytes for a session (plan §6.7: over the shared mTLS OkHttp client). */
public fun interface PreviewSource {
    public suspend fun fetch(sessionId: UUID): SessionPreview
}

/**
 * Rasterises a [SessionPreview] into a `Bitmap` (the off-screen-emulator → `Canvas` cell painter). The
 * seam boundary: this is the ONLY android.graphics-touching surface, kept behind an interface so the
 * pipeline's cache/concurrency/dedup policy is JVM-unit-testable and the pixel raster is device-verified.
 */
public interface ThumbnailRasterizer {
    /** Off-screen emulator append + per-cell `Canvas` draw. */
    public fun rasterize(preview: SessionPreview): Bitmap

    /** A cheap solid bitmap cached on any fetch/render failure (no retry storm). */
    public fun placeholder(): Bitmap
}

/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */
public interface BitmapCache {
    public fun get(key: ThumbnailKey): Bitmap?
    public fun put(key: ThumbnailKey, bitmap: Bitmap)
}

/**
 * Production [BitmapCache] backed by an [LruCache] sized by each bitmap's allocation byte count (plan
 * §6.7). Instantiated only in production (the pipeline's default arg); unit tests inject a map-backed fake
 * so `android.util.LruCache` — a stub off-device — is never touched under JVM test.
 */
public class LruBitmapCache(maxBytes: Int) : BitmapCache {
    private val lru = object : LruCache<ThumbnailKey, Bitmap>(maxBytes) {
        override fun sizeOf(key: ThumbnailKey, value: Bitmap): Int = value.allocationByteCount.coerceAtLeast(1)
    }

    override fun get(key: ThumbnailKey): Bitmap? = lru.get(key)
    override fun put(key: ThumbnailKey, bitmap: Bitmap) {
        lru.put(key, bitmap)
    }
}

/**
 * Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
 * tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
 * the server's own cap (plan §6.7 / §4.2).
 */
public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource {
    override suspend fun fetch(sessionId: UUID): SessionPreview =
        PreviewCap.cap(apiClient.preview(sessionId))
}

/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */
public object PreviewCap {
    /** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */
    public const val MAX_PREVIEW_BYTES: Int = 256 * 1024

    /**
     * Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen)
     * so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at
     * worst mangles one leading glyph — harmless for a thumbnail.
     */
    public fun cap(preview: SessionPreview): SessionPreview {
        val bytes = preview.data.toByteArray(Charsets.UTF_8)
        if (bytes.size <= MAX_PREVIEW_BYTES) return preview
        val tail = bytes.copyOfRange(bytes.size - MAX_PREVIEW_BYTES, bytes.size)
        return preview.copy(data = String(tail, Charsets.UTF_8))
    }
}
