package com.fabledsword.minstrel.shared

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean

// Once raised, the veil stays up at least this long. Without a floor a
// no-op refresh wipes on and straight back off, which reads as a glitch.
private const val DEFAULT_MIN_HOLD_MS = 900L

// The screen must stop changing for this long before the veil lowers.
// Every content change re-arms it, so a refresh that lands in stages
// (index → tile hydration → artwork) holds the veil across all of them.
private const val DEFAULT_QUIET_MS = 700L

// Hard ceiling on visibility. A refresh that never settles must not
// strand the user behind an opaque veil; the work itself is NOT capped.
private const val DEFAULT_MAX_HOLD_MS = 12_000L

// Attempts per session. Retrying behind the veil is the point: a pull
// that fails on the first try gets another go before the user sees
// anything, instead of the veil wiping off over unchanged content.
private const val DEFAULT_ATTEMPTS = 3
private const val DEFAULT_RETRY_BACKOFF_MS = 600L

/** Tunables for [UpdateVeilController]; defaults are the Home values. */
data class VeilTimings(
    val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
    val quietMs: Long = DEFAULT_QUIET_MS,
    val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
    val attempts: Int = DEFAULT_ATTEMPTS,
    val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
)

/**
 * A snapshot of everything that visibly moves on the veiled screen.
 *
 * @param contentKey any value whose equality tracks what's rendered — a
 *   change means the screen moved, and re-arms the quiet timer.
 * @param hasContent true when real content (not a skeleton or an empty
 *   state) is on screen. The veil waits for this before raising: there's
 *   nothing to hide until there's something to hide.
 * @param quiescent false while something is still landing (artwork
 *   loading, tiles hydrating). The veil will not lower until this is
 *   true, up to [VeilTimings.maxHoldMs].
 */
data class VeilSettleState(
    val contentKey: Any?,
    val hasContent: Boolean,
    val quiescent: Boolean,
)

/** What a finished session did, so callers can report it if they want. */
enum class VeilOutcome {
    /** Content changed, and the veil covered the churn. */
    CHANGED,

    /** The refresh worked, but nothing on screen moved — already current. */
    UNCHANGED,

    /** Every attempt failed. */
    FAILED,
}

/**
 * One session's result, plus whether a user explicitly asked for it.
 *
 * [userInitiated] is what lets a caller tell feedback from noise: a user
 * who pulled to refresh is owed an answer even when the answer is "nothing
 * changed", while the same outcome from a background check is noise.
 */
data class VeilSessionResult(
    val outcome: VeilOutcome,
    val userInitiated: Boolean,
)

/**
 * Drives an "updating" overlay from *observed content change and settling*
 * rather than from a fixed delay.
 *
 * The problem this replaces: a veil held for `refresh().join() + 500ms`
 * lowers while the screen is still moving, because finishing the network
 * pull is nowhere near the end of the visible work — the pull writes id
 * lists, then tiles hydrate one by one, then artwork loads. And a plain
 * `isUpdating` Boolean set in a `finally` gets cleared by whichever of
 * two overlapping refreshes finishes first, wiping the veil off mid-update
 * (issue #2327).
 *
 * So instead: run [work], raise only if the content actually changes, then
 * hold until [settleSignal] reports the screen has stopped changing for
 * [VeilTimings.quietMs] AND is quiescent — bounded below by
 * [VeilTimings.minHoldMs] so it can never flash, and above by
 * [VeilTimings.maxHoldMs] so it can never strand.
 *
 * The raise is deliberately *reactive*: a refresh that returns what's
 * already on screen — the common case on a launch over a warm cache —
 * raises nothing at all, because a veil over an unchanged screen hides
 * nothing and only delays first paint. The cost is that the veil arrives
 * one emission after the change, so a single atomic content swap shows
 * through; everything messier that follows it (tile hydration, then
 * artwork) still lands behind the veil.
 *
 * Overlapping triggers extend the running session instead of racing it,
 * so the veil stays up continuously rather than lowering and re-raising.
 *
 * Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
 * behind the veil, and if they all fail the veil simply wipes off over the
 * cached content. Giving up ends only *this* session — it sets no latch and
 * blocks nothing, so the caller's own recovery paths (reconnect re-pull,
 * freshness sweeps, the next event, a manual pull) keep retrying afterwards
 * exactly as before. Callers that want to surface a failure can do it from
 * [onSessionEnd] instead.
 *
 * @param work one refresh attempt; returns true when it succeeded.
 * @param shouldVeil sampled at session start — "is there cached content
 *   this refresh is about to overwrite?". False means a cold load, where
 *   a skeleton is the right affordance, and the work runs unveiled.
 * @param onSessionEnd called once per finished session, on the controller's
 *   coroutine. Use it for user-facing feedback the veil itself can't give.
 */
class UpdateVeilController(
    private val scope: CoroutineScope,
    private val settleSignal: Flow<VeilSettleState>,
    private val shouldVeil: suspend () -> Boolean,
    private val timings: VeilTimings = VeilTimings(),
    private val onSessionEnd: (VeilSessionResult) -> Unit = {},
    private val work: suspend () -> Boolean,
) {
    private val visibleInternal = MutableStateFlow(false)

    /** True while the veil should be drawn over the screen. */
    val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()

    private val finishedInternal = MutableStateFlow(0)

    /**
     * Increments as each session ends. Lets a caller wait for "this
     * refresh is done" without knowing whether a veil ever went up —
     * a pull-to-refresh indicator needs exactly that, since an unchanged
     * refresh never raises one.
     */
    val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()

    // Conflated: a burst of triggers (reconnect + rebuild event arriving
    // together) collapses into one follow-up pass, not a queue of them.
    private val requests = Channel<Unit>(Channel.CONFLATED)

    // Sticky across a conflated burst: conflation drops the older token, so
    // the "a user asked for this" bit can't ride on it. If ANY coalesced
    // trigger was the user's, the session still owes them an answer.
    private val userAsked = AtomicBoolean(false)

    init {
        // One consumer, so sessions are serialised by construction: two
        // triggers can never each own a piece of the veil's state.
        scope.launch {
            while (true) {
                requests.receive()
                runSession()
            }
        }
    }

    /**
     * Ask for a refresh. Safe to call from any trigger at any rate —
     * calls arriving during a session extend it rather than starting a
     * competing one.
     *
     * @param userInitiated true when a person explicitly asked (pull to
     *   refresh, a Retry button), which is what [VeilSessionResult] carries
     *   through to [onSessionEnd].
     */
    fun request(userInitiated: Boolean = false) {
        if (userInitiated) userAsked.set(true)
        requests.trySend(Unit)
    }

    private suspend fun runSession() {
        // Sampled at both ends of the work: a trigger folded in mid-session
        // (see [drainWork]) may have been the user's, and they're still owed
        // an answer for it.
        val askedAtStart = userAsked.getAndSet(false)
        if (!shouldVeil()) {
            // Cold load: the skeleton is the right affordance, so no veil.
            // Succeeding here did change the screen — from nothing to
            // something — so it reports CHANGED, never "already up to date".
            val ok = drainWork()
            finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
            return
        }
        val raised = CompletableDeferred<Unit>()
        val raiser = scope.launch { raiseWhenContentChanges(raised) }
        // Floor and ceiling are measured from the raise, not the request,
        // so a late raise still gets its full no-flash minimum.
        val floor = scope.launch {
            raised.await()
            delay(timings.minHoldMs)
        }
        val ceiling = scope.launch {
            raised.await()
            delay(timings.maxHoldMs)
            visibleInternal.value = false
        }
        var succeeded = false
        try {
            succeeded = drainWork()
            // Always wait for the settle, never conditionally on `visible`:
            // work that finishes without suspending would otherwise reach
            // here before the raiser has been dispatched, tear the session
            // down, and leave the churn uncovered. This wait is also what
            // makes `raised.isCompleted` below a trustworthy "did anything
            // change?" — a change landing just after the pull returns still
            // gets seen.
            withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
            // Honour the no-flash minimum before lowering. Deliberately in
            // the try and not the finally: on cancellation the scope is
            // going away and nothing will render the veil, so the floor is
            // pointless there — and a finally that suspends is a finally
            // that can resist teardown.
            if (raised.isCompleted) floor.join()
        } finally {
            raiser.cancel()
            ceiling.cancel()
            floor.cancel()
            visibleInternal.value = false
            finish(
                succeeded = succeeded,
                changed = raised.isCompleted,
                userInitiated = askedAtStart,
            )
        }
    }

    /**
     * Raises the veil the moment the screen's content differs from what was
     * already on it — and never, if this refresh turns out to be a no-op.
     *
     * The baseline is the first state that HAS content, not simply the first
     * state: over a warm cache the cached rows paint a moment after the
     * session starts, and treating that first paint as "a change" would veil
     * every launch, which is the whole thing this avoids.
     */
    private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
        val baseline = settleSignal.first { it.hasContent }
        settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
        visibleInternal.value = true
        raised.complete(Unit)
    }

    private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
        val outcome = when {
            !succeeded -> VeilOutcome.FAILED
            changed -> VeilOutcome.CHANGED
            else -> VeilOutcome.UNCHANGED
        }
        onSessionEnd(
            VeilSessionResult(
                outcome = outcome,
                // Fold in a mid-session request from the user.
                userInitiated = userInitiated || userAsked.getAndSet(false),
            ),
        )
        finishedInternal.update { it + 1 }
    }

    /** True when the refresh eventually succeeded. */
    private suspend fun drainWork(): Boolean {
        var succeeded: Boolean
        do {
            succeeded = runWorkWithRetries()
            // A trigger that arrived mid-session gets folded into this one.
        } while (requests.tryReceive().isSuccess)
        return succeeded
    }

    private suspend fun runWorkWithRetries(): Boolean {
        repeat(timings.attempts) { attempt ->
            if (work()) return true
            if (attempt < timings.attempts - 1) {
                delay(timings.retryBackoffMs * (attempt + 1))
            }
        }
        return false
    }

    /**
     * Suspends until the screen has been unchanged for
     * [VeilTimings.quietMs] and reports itself quiescent.
     *
     * `debounce` is what makes this hold across a staged update: every
     * change restarts the window, so the veil lowers only once emissions
     * actually stop. `first { quiescent }` then rejects a quiet-but-
     * still-loading moment and waits for the next lull.
     */
    @OptIn(FlowPreview::class)
    private suspend fun awaitSettled() {
        settleSignal
            .distinctUntilChanged()
            .debounce(timings.quietMs)
            .first { it.quiescent }
    }
}
