package com.doctate.watch.sync

import android.util.Log
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.net.OnelinersClient
import com.doctate.watch.net.PollOutcome
import com.doctate.watch.net.SnapshotCache
import com.doctate.watch.net.UploadResult
import com.doctate.watch.domain.parseOnelinersResponse  // re-export only for callers
import com.doctate.watch.domain.OnelinersResponse
import com.doctate.watch.domain.OnelinerEntry
import com.doctate.watch.net.CachedSnapshot
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import java.time.Instant

/**
 * Pure upload + poll worker, testable on the JVM. Mirrors the loop in
 * `doctate-desktop::server_sync::run` — uploads have priority over
 * polling.
 *
 * The worker exposes [runOnce] for direct test invocation and [run] for
 * the production infinite-loop variant. Splitting them keeps tests off
 * the `runTest` ↔ infinite-loop tarpit (advanceUntilIdle has no
 * stopping condition against an unconditional while).
 *
 * If [poller] is null the loop is upload-only — this is the Phase 4
 * shape; Phase 6 wires in the real `OnelinersClient` so the idle branch
 * does conditional GET against `/api/oneliners`.
 */
class SyncWorkerLoop(
    private val pendingStore: PendingStore,
    private val uploader: Uploader,
    private val caseStore: CaseStore,
    private val state: SyncStateProvider,
    private val kickChannel: Channel<SyncKick>,
    private val poller: OnelinersClient? = null,
    private val snapshotCache: SnapshotCache? = null,
    private val initialBackoffMs: Long = INITIAL_BACKOFF_MS,
    private val maxBackoffMs: Long = MAX_BACKOFF_MS,
    private val pollIntervalMsProvider: () -> Long = { DEFAULT_POLL_INTERVAL_MS },
    private val nowMs: () -> Long = { System.currentTimeMillis() },
) {
    /** Functional seam: takes the same args as `UploadClient.upload`. */
    fun interface Uploader {
        suspend fun upload(caseId: String, recordedAt: String, audio: File): UploadResult
    }

    /** Outcome of a single iteration. */
    sealed interface Step {
        /** A pending upload was processed. */
        data class Uploaded(val result: UploadResult) : Step
        /** Idle branch ran a poll. */
        data class Polled(val outcome: PollOutcome) : Step
        /** Idle branch with no poller wired (test/Phase-4 shape). */
        data object Idle : Step
    }

    private var backoffMs: Long = initialBackoffMs
    private var etag: String? = null

    // Post-stop burst polling state. Active when a BurstPoll kick has arrived
    // and the deadline has not yet passed and the named case still lacks a
    // text-bearing oneliner. While active, [currentPollIntervalMs] returns
    // [BURST_POLL_INTERVAL_MS] instead of [DEFAULT_POLL_INTERVAL_MS] — the
    // perceived "time-to-oneliner" after stop is what tells the doctor
    // whether the automation works, so we burn an extra 30 polls in 60s
    // for a chance at sub-five-second feedback.
    private var burstCaseId: String? = null
    private var burstDeadlineMs: Long = 0L

    suspend fun runOnce(): Step {
        val pending = pendingStore.scan()
        val next = pending.firstOrNull()
        if (next != null) {
            publish(WorkerPhase.Uploading, pending)
            val result = uploader.upload(next.caseId, next.recordedAt, next.file)
            applyUploadResult(next, result)
            return Step.Uploaded(result)
        }
        if (poller != null) {
            publish(WorkerPhase.Polling, emptyList())
            val outcome = poller.poll(etag)
            applyPollOutcome(outcome)
            publish(WorkerPhase.Idle, emptyList())
            return Step.Polled(outcome)
        }
        publish(WorkerPhase.Idle, emptyList())
        return Step.Idle
    }

    /** Infinite loop variant for production. Idle branch waits on kick or poll interval. */
    @OptIn(ExperimentalCoroutinesApi::class)
    suspend fun run() {
        // Cold-start prime: load the cached snapshot so the UI has data
        // before any network call lands. Etag is hydrated so the very first
        // poll can be a 304.
        snapshotCache?.load()?.let { cached ->
            try {
                caseStore.mergeServerSnapshot(cached.response)
                etag = cached.etag
                Log.i(TAG, "cold-start primed from snapshot cache")
            } catch (e: Exception) {
                Log.w(TAG, "snapshot prime failed: ${e.message}")
            }
        }
        while (currentCoroutineContext().isActive) {
            when (runOnce()) {
                is Step.Uploaded -> Unit // proceed immediately
                is Step.Polled, Step.Idle -> waitForKickOrTimeout()
            }
        }
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun waitForKickOrTimeout() {
        select<Unit> {
            onTimeout(currentPollIntervalMs()) {}
            kickChannel.onReceiveCatching { res ->
                res.getOrNull()?.let { handleKick(it) }
            }
        }
    }

    private fun handleKick(kick: SyncKick) {
        when (kick) {
            SyncKick.NewPending -> Unit // wake up, next iteration scans
            is SyncKick.BurstPoll -> {
                burstCaseId = kick.caseId
                burstDeadlineMs = kick.deadlineMs
                Log.i(TAG, "burst-poll started caseId=${kick.caseId.take(8)} deadline=${kick.deadlineMs}")
            }
        }
    }

    /** Effective poll interval: 2 s while a burst is active, else the configured default. */
    private fun currentPollIntervalMs(): Long {
        if (burstActive()) return BURST_POLL_INTERVAL_MS
        return pollIntervalMsProvider()
    }

    private fun burstActive(): Boolean {
        if (burstCaseId == null) return false
        if (nowMs() >= burstDeadlineMs) {
            Log.i(TAG, "burst-poll budget expired caseId=${burstCaseId?.take(8)}")
            burstCaseId = null
            return false
        }
        return true
    }

    /** Test-only: trigger the kick handler synchronously. */
    internal fun applyKickForTest(kick: SyncKick) = handleKick(kick)

    private suspend fun applyUploadResult(next: PendingUpload, result: UploadResult) {
        when (result) {
            is UploadResult.Success -> {
                pendingStore.deletePair(next.file)
                caseStore.markSynced(next.caseId)
                state.recordSuccess(nowMs())
                backoffMs = initialBackoffMs
                // Self-kick a burst poll for this case — gives the user a
                // sub-five-second oneliner if the LLM is fast.
                kickChannel.trySend(SyncKick.BurstPoll(next.caseId, nowMs() + BURST_POLL_BUDGET_MS))
                Log.i(TAG, "upload ok caseId=${next.caseId.take(8)} status=${result.status}")
            }
            is UploadResult.Transient -> {
                state.recordFailure(nowMs(), result.reason)
                Log.w(TAG, "transient: ${result.reason} → backoff ${backoffMs}ms")
                delay(backoffMs)
                backoffMs = (backoffMs * 2).coerceAtMost(maxBackoffMs)
            }
            is UploadResult.Terminal -> {
                Log.w(TAG, "terminal: ${result.reason} → dropping caseId=${next.caseId.take(8)}")
                pendingStore.deletePair(next.file)
                state.recordFailure(nowMs(), result.reason)
                backoffMs = initialBackoffMs
            }
        }
    }

    private suspend fun applyPollOutcome(outcome: PollOutcome) {
        when (outcome) {
            is PollOutcome.Success -> {
                caseStore.mergeServerSnapshot(outcome.response)
                caseStore.reconcileWithServerSnapshot(outcome.response)
                if (outcome.etag != null) {
                    snapshotCache?.store(
                        CachedSnapshot(
                            etag = outcome.etag,
                            fetchedAt = Instant.ofEpochMilli(nowMs()).toString(),
                            response = outcome.response,
                        ),
                    )
                    etag = outcome.etag
                } else {
                    Log.w(TAG, "server returned 200 without ETag — conditional requests disabled")
                    etag = null
                }
                state.recordSuccess(nowMs())
                // Burst poll: did the case the user just stopped on get its oneliner?
                burstCaseId?.let { caseId ->
                    val entry = outcome.response.oneliners.firstOrNull { it.caseId == caseId }
                    if (entry?.oneliner != null) {
                        Log.i(TAG, "burst-poll hit caseId=${caseId.take(8)} → returning to default interval")
                        burstCaseId = null
                    }
                }
            }
            is PollOutcome.NotModified -> {
                state.recordSuccess(nowMs())
            }
            is PollOutcome.TransientHttp -> {
                state.recordFailure(nowMs(), "poll HTTP ${outcome.code}")
                Log.w(TAG, "poll transient: HTTP ${outcome.code}")
            }
            is PollOutcome.Network -> {
                state.recordFailure(nowMs(), "poll: ${outcome.cause.message}")
                Log.w(TAG, "poll network: ${outcome.cause.message}")
            }
        }
    }

    private fun publish(phase: WorkerPhase, pending: List<PendingUpload>) {
        val ids = pending.map { it.caseId }.toSet()
        when (phase) {
            WorkerPhase.Idle -> state.setIdle(pending.size, ids)
            WorkerPhase.Uploading -> state.setUploading(pending.size, ids)
            WorkerPhase.Polling -> state.setPolling(pending.size, ids)
        }
    }

    /** Test-only accessors. */
    internal val currentBackoffMs: Long get() = backoffMs
    internal val currentEtag: String? get() = etag
    internal val isBurstActive: Boolean get() = burstActive()
    internal val effectivePollIntervalMs: Long get() = currentPollIntervalMs()

    companion object {
        private const val TAG = "SyncWorkerLoop"
        const val INITIAL_BACKOFF_MS: Long = 2_000L
        const val MAX_BACKOFF_MS: Long = 60_000L
        const val DEFAULT_POLL_INTERVAL_MS: Long = 30_000L
        const val BURST_POLL_INTERVAL_MS: Long = 2_000L
        const val BURST_POLL_BUDGET_MS: Long = 60_000L
    }
}
