HttpEngine.kt

package com.shengzhiai.yugu.internal

import com.shengzhiai.yugu.AudioBufferPolicy
import com.shengzhiai.yugu.AudioPrecheckMode
import com.shengzhiai.yugu.Auth
import com.shengzhiai.yugu.CancellationToken
import com.shengzhiai.yugu.Log
import com.shengzhiai.yugu.ReconnectPolicy
import com.shengzhiai.yugu.RequestOptions
import com.shengzhiai.yugu.RetryPolicy
import com.shengzhiai.yugu.Signer
import com.shengzhiai.yugu.YuguErrors
import com.shengzhiai.yugu.YuguEventListener
import com.shengzhiai.yugu.YuguException
import okhttp3.Call
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.IdentityHashMap
import java.util.Locale
import java.util.Random
import java.util.UUID
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit

/** Resolved client settings (DESIGN 2.1). */
internal class ClientConfig(
    val baseUrl: String,
    val wsBaseUrl: String,
    val auth: Auth,
    val connectTimeoutMs: Long,
    val readTimeoutMs: Long,
    val totalTimeoutMs: Long,
    val retryPolicy: RetryPolicy,
    val autoIdempotencyKey: Boolean,
    val eventListener: YuguEventListener,
    val audioPrecheck: AudioPrecheckMode,
    val strictAudio: Boolean,
    val userAgent: String,
    val reconnectPolicy: ReconnectPolicy,
    val audioBufferPolicy: AudioBufferPolicy,
    val resultTimeoutMs: Long,
    val pingIntervalMs: Long,
    val maxReplayBytes: Long,
    val callbackExecutor: Executor,
    val random: Random,
)

/** Idempotency key rules (DESIGN 2.2). */
internal object IdempotencyKeys {
    private val ALLOWED = Regex("^[\\x21-\\x7E]{1,200}$")

    fun generate(): String = UUID.randomUUID().toString().replace("-", "")

    fun isValid(key: String): Boolean = ALLOWED.matches(key)

    /** Caller key validated before any I/O, or a generated key, or null when auto keys are off. */
    fun resolve(callerKey: String?, auto: Boolean): String? {
        if (callerKey != null) {
            if (!isValid(callerKey)) {
                throw YuguErrors.local(90010, "幂等键须为 1 到 200 个可见 ASCII 字符")
            }
            return callerKey
        }
        return if (auto) generate() else null
    }
}

/**
 * Tracks what a client owns so close() can cancel it: in-flight REST calls and open sessions.
 */
internal class Lifecycle {
    @Volatile
    var closed = false
        private set
    private val calls = Collections.newSetFromMap(IdentityHashMap<CallToken, Boolean>())
    private val sessions = Collections.newSetFromMap(IdentityHashMap<Cancellable, Boolean>())

    interface Cancellable {
        fun cancel()
    }

    fun ensureOpen() {
        if (closed) throw YuguErrors.local(90004)
    }

    @Synchronized
    fun register(token: CallToken) {
        if (closed) throw YuguErrors.local(90004)
        calls.add(token)
    }

    @Synchronized
    fun unregister(token: CallToken) {
        calls.remove(token)
    }

    @Synchronized
    fun register(s: Cancellable) {
        if (closed) throw YuguErrors.local(90004)
        sessions.add(s)
    }

    @Synchronized
    fun unregister(s: Cancellable) {
        sessions.remove(s)
    }

    @Synchronized
    fun activeSessions(): Int = sessions.size

    @Synchronized
    fun activeCalls(): Int = calls.size

    /** Returns false when already closed. */
    fun close(): Boolean {
        val toCancelCalls: List<CallToken>
        val toCancelSessions: List<Cancellable>
        synchronized(this) {
            if (closed) return false
            closed = true
            toCancelCalls = ArrayList(calls)
            toCancelSessions = ArrayList(sessions)
            calls.clear()
            sessions.clear()
        }
        for (s in toCancelSessions) s.cancel()
        for (c in toCancelCalls) c.cancel(CallToken.Reason.CLIENT_CLOSED)
        return true
    }
}

/** Cancellation state of one logical REST call: caller token plus client close. */
internal class CallToken(userToken: CancellationToken?) {
    enum class Reason { USER, CLIENT_CLOSED }

    private val signal = CancellationToken()

    @Volatile
    var reason: Reason? = null
        private set

    @Volatile
    private var call: Call? = null
    private val unregisterUser: (() -> Unit)? = userToken?.register { cancel(Reason.USER) }

    val cancelled: Boolean get() = reason != null

    fun cancel(r: Reason) {
        if (reason == null) reason = r
        call?.cancel()
        signal.cancel()
    }

    fun attach(c: Call) {
        call = c
        if (cancelled) c.cancel()
    }

    /** Sleeps [ms]; returns true when cancelled meanwhile. */
    fun sleep(ms: Long): Boolean = try {
        signal.await(ms)
    } catch (e: InterruptedException) {
        Thread.currentThread().interrupt()
        cancel(Reason.USER)
        true
    }

    fun dispose() {
        unregisterUser?.invoke()
    }

    fun error(cause: Throwable? = null): YuguException = when (reason) {
        Reason.CLIENT_CLOSED -> YuguErrors.local(90004, "客户端已关闭,调用被取消", cause)
        else -> YuguErrors.local(90003, cause = cause)
    }
}

/** One REST request description. */
internal class HttpRequestSpec(
    val op: String,
    val method: String,
    val path: String,
    val body: RequestBody?,
    /** Business parameters that are signed (DESIGN 5.2). */
    val signParams: Map<String, String>,
    /** Write calls carry an Idempotency-Key and are retried only with a key. */
    val write: Boolean,
)

internal class HttpResponseData(val status: Int, val body: String, val headers: Headers) {
    val replayed: Boolean get() = headers["Idempotency-Replayed"].equals("true", ignoreCase = true)
}

/**
 * REST execution with retries (DESIGN 2.3): same idempotency key on every attempt, fresh
 * timestamp and nonce per attempt, backoff with jitter, Retry-After, total deadline.
 */
internal class HttpEngine(
    private val cfg: ClientConfig,
    private val log: Log,
    private val client: OkHttpClient,
    private val lifecycle: Lifecycle,
) {
    private val clock: () -> Long = { System.nanoTime() / 1_000_000L }

    fun <T> execute(spec: HttpRequestSpec, options: RequestOptions, parse: (HttpResponseData, String?) -> T): T {
        lifecycle.ensureOpen()
        if (Platform.isMainThread()) {
            log.w("${spec.op} 是阻塞调用,在主线程调用会被系统拒绝,请在后台线程调用或改用 ${spec.op}Async")
        }
        val policy = options.retryPolicy ?: cfg.retryPolicy
        val totalTimeout = options.totalTimeoutMs ?: cfg.totalTimeoutMs
        val readTimeout = options.readTimeoutMs ?: cfg.readTimeoutMs
        val key = if (spec.write) IdempotencyKeys.resolve(options.idempotencyKey, cfg.autoIdempotencyKey) else null
        val retryAllowed = !spec.write || key != null
        if (!retryAllowed) log.d("${spec.op}: autoIdempotencyKey 已关闭且未指定幂等键,写请求不重试")
        val signature = (cfg.auth as? Auth.Signature)?.let { Signer.sign(spec.signParams, it.secretKey) }
        val http = if (readTimeout == cfg.readTimeoutMs) client else client.newBuilder().readTimeout(readTimeout, TimeUnit.MILLISECONDS).build()

        val token = CallToken(options.cancellationToken)
        lifecycle.register(token)
        val start = clock()
        val deadline = start + totalTimeout
        var attempt = 0
        try {
            while (true) {
                attempt++
                if (token.cancelled) throw finish(spec, token.error(), key, attempt - 1, start)
                val remaining = deadline - clock()
                safeListener { cfg.eventListener.onRequestStart(spec.op, spec.method, spec.path, attempt) }
                log.d { "${spec.op} attempt $attempt ${spec.method} ${spec.path} idempotencyKey=$key" }
                var error: YuguException = try {
                    val resp = executeOnce(http, spec, key, signature, remaining.coerceAtLeast(1), token)
                    if (resp.status in 200..299) {
                        val value = parse(resp, key)
                        safeListener { cfg.eventListener.onRequestEnd(spec.op, resp.status, clock() - start, attempt, null) }
                        log.d { "${spec.op} ok status=${resp.status} attempts=$attempt replayed=${resp.replayed}" }
                        return value
                    }
                    YuguErrors.fromHttp(resp.status, resp.body, retryAfterMs(resp.headers))
                } catch (e: YuguException) {
                    e
                } catch (e: IOException) {
                    YuguErrors.fromIOException(e, cancelled = false)
                }
                if (token.cancelled) {
                    if (error.code != CANCELLED_CODE && error.code != CLOSED_CODE) error = token.error(error)
                    throw finish(spec, error, key, attempt, start)
                }
                error.withContext(key, attempt)
                val canRetry = retryAllowed && attempt <= policy.maxRetries && YuguErrors.isRetryable(error)
                if (!canRetry) throw finish(spec, error, key, attempt, start)
                val delay = policy.delayMs(attempt, cfg.random, error.retryAfterMs)
                if (clock() + delay > deadline) {
                    log.w("${spec.op}: 总时限 ${totalTimeout} ms 内不足以再次重试,停止重试:${describe(error)}")
                    throw finish(spec, error, key, attempt, start)
                }
                log.w("retry $attempt/${policy.maxRetries} in $delay ms: ${describe(error)}")
                safeListener { cfg.eventListener.onRetry(spec.op, attempt, delay, error) }
                if (token.sleep(delay)) throw finish(spec, token.error(error), key, attempt, start)
            }
        } finally {
            token.dispose()
            lifecycle.unregister(token)
        }
    }

    private fun finish(spec: HttpRequestSpec, error: YuguException, key: String?, attempts: Int, start: Long): YuguException {
        error.withContext(key, attempts)
        safeListener { cfg.eventListener.onRequestEnd(spec.op, error.httpStatus, clock() - start, attempts, error) }
        log.i("${spec.op} failed after $attempts attempt(s): $error")
        return error
    }

    private fun executeOnce(
        http: OkHttpClient,
        spec: HttpRequestSpec,
        key: String?,
        signature: String?,
        remainingMs: Long,
        token: CallToken,
    ): HttpResponseData {
        val b = Request.Builder()
            .url(cfg.baseUrl.trimEnd('/') + spec.path)
            .header("User-Agent", cfg.userAgent)
            .header("Accept", "application/json")
        when (val a = cfg.auth) {
            is Auth.Token -> b.header("Authorization", "Bearer " + a.token)
            is Auth.Signature -> {
                b.header("X-App-Key", a.appKey)
                b.header("X-Timestamp", (System.currentTimeMillis() / 1000).toString())
                b.header("X-Nonce", IdempotencyKeys.generate())
                b.header("X-Signature", signature ?: "")
            }
        }
        if (key != null) b.header("Idempotency-Key", key)
        if (spec.method == "GET") b.get() else b.method(spec.method, spec.body)
        val call = http.newCall(b.build())
        call.timeout().timeout(remainingMs, TimeUnit.MILLISECONDS)
        token.attach(call)
        call.execute().use { resp ->
            val text = resp.body?.string() ?: ""
            return HttpResponseData(resp.code, text, resp.headers)
        }
    }

    private fun safeListener(block: () -> Unit) {
        try {
            block()
        } catch (t: Throwable) {
            log.w("eventListener 抛出异常,已忽略", t)
        }
    }

    companion object {
        private const val CANCELLED_CODE = 90003
        private const val CLOSED_CODE = 90004

        fun describe(e: YuguException): String =
            if (e.httpStatus > 0) "HTTP ${e.httpStatus} code=${e.code}" else "${e.category} code=${e.code}"

        /** Retry-After as delta seconds (decimals allowed) or HTTP-date, in milliseconds. */
        fun retryAfterMs(headers: Headers): Long? {
            val v = headers["Retry-After"]?.trim() ?: return null
            v.toDoubleOrNull()?.let { return if (it >= 0) (it * 1000).toLong() else null }
            return try {
                val fmt = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
                val at = fmt.parse(v)?.time ?: return null
                (at - System.currentTimeMillis()).coerceAtLeast(0)
            } catch (e: Exception) {
                null
            }
        }
    }
}