package dev.kaizen.app.net

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import android.util.Log
import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit

// --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) ---

@Serializable private data class TokenRequest(val email: String, val password: String, val name: String)
@Serializable private data class TokenResponse(val token: String)
@Serializable data class MeResponse(
    val name: String? = null,
    val email: String? = null,
    val defaultModel: String? = null,
    val ttsVoice: String? = null,
)
@Serializable data class WireMessage(val role: String, val content: String)
@Serializable private data class ChatRequest(
    val model: String,
    val messages: List<WireMessage>,
    val reasoningEffort: String? = null,
    val preferThroughput: Boolean? = null,
    val attachments: List<Attachment>? = null,
    val webSearch: Boolean? = null,
    val webSearchProvider: String? = null,
    val temperature: Float? = null,
    val topP: Float? = null,
    val topK: Int? = null,
)

/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@Serializable
data class KaizenModel(
    val id: String,
    val name: String? = null,
    val provider: String? = null,
    val context_length: Int? = null,
)

@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())

/** A conversation as shown in the sidebar list. Extra fields from the API are ignored. */
@Serializable
data class ConversationSummary(
    val id: String,
    val title: String = "",
    val locked: Boolean = false,
    val pinned: Boolean = false,
    val updatedAt: String? = null,
    val titleGenerated: Boolean = false,
)

@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@Serializable private data class UnlockRequest(val password: String? = null)
@Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String)
@Serializable private data class TitleResponse(val title: String? = null)

@Serializable data class AppDevice(
    val id: String,
    val name: String = "",
    val lastUsedAt: String? = null,
    val createdAt: String = "",
    val isCurrent: Boolean = false,
)
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)

/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@Serializable
data class Attachment(
    val url: String,
    val kind: String = "",
    val name: String = "",
    val mimeType: String = "",
    val size: Long? = null,
    val fileId: String? = null,
)

/** One persisted message from GET /api/v1/conversations/[id]. */
@Serializable
data class StoredMessage(
    val id: String,
    val role: String,
    val content: String = "",
    val parentId: String? = null,
    val attachments: List<Attachment> = emptyList(),
    val reasoning: String? = null,
    val sources: List<SearchSource>? = null,
    val model: String? = null,
    val inputTokens: Int? = null,
    val outputTokens: Int? = null,
)

@Serializable private data class ConversationDetail(
    val messages: List<StoredMessage> = emptyList(),
    val activeRootId: String? = null,
    val activeChild: Map<String, String> = emptyMap(),
)

data class ConversationData(
    val messages: List<StoredMessage> = emptyList(),
    val activeRootId: String? = null,
    val activeChild: Map<String, String> = emptyMap(),
)

/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
@Serializable
data class SaveMessage(
    val id: String,
    val parentId: String?,
    val role: String,
    val content: String,
    val kind: String = "chat",
    val model: String? = null,
    val attachments: List<Attachment>? = null,
    val reasoning: String? = null,
    val sources: List<SearchSource>? = null,
)

@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)

/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
sealed interface LoginResult {
    data class Success(val token: String) : LoginResult
    data object InvalidCredentials : LoginResult // 401
    data object RateLimited : LoginResult        // 429
    data class Error(val message: String) : LoginResult
}

/** Thrown by [KaizenApi.chat] when the backend rejects the request before streaming. */
class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code")

/** Result of a fetch that can either succeed or fail with a diagnostic message. */
sealed interface FetchResult<out T> {
    data class Ok<T>(val data: T) : FetchResult<T>
    data class Fail(val reason: String) : FetchResult<Nothing>
}

private fun diagnoseFetchError(endpoint: String, e: Exception): String {
    val name = e.javaClass.simpleName
    return when {
        e is java.net.UnknownHostException -> "DNS: Host nicht gefunden"
        e is java.net.ConnectException -> "Verbindung abgelehnt (${e.message})"
        e is java.net.SocketTimeoutException -> "Timeout ($endpoint)"
        e is javax.net.ssl.SSLException -> "SSL/TLS: ${e.message}"
        e is javax.net.ssl.SSLHandshakeException -> "SSL-Zertifikat ungültig"
        e is java.io.EOFException -> "Verbindung unerwartet geschlossen"
        e is kotlinx.serialization.SerializationException -> "JSON-Parsing: ${e.message?.take(80)}"
        else -> "$name: ${e.message?.take(100)}"
    }
}

/**
 * Thin client over the Kaizen v1 API. One uniform `Authorization: Bearer` contract,
 * same against the official server or any self-host. The chat response is a
 * `text/plain` chunked stream (NOT SSE) parsed incrementally by [StreamConsumer].
 */
object KaizenApi {

    private const val TAG = "KaizenApi"

    private val client = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
        .build()

    val imageClient: OkHttpClient = client.newBuilder()
        .readTimeout(30, TimeUnit.SECONDS)
        .build()

    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
    private val jsonMedia = "application/json".toMediaType()

    /** POST /api/v1/auth/token — exchange email+password for a `kzn_` bearer token. */
    suspend fun login(baseUrl: String, email: String, password: String, deviceName: String): LoginResult =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(TokenRequest(email, password, deviceName)).toRequestBody(jsonMedia)
            val req = Request.Builder().url("$baseUrl/api/v1/auth/token").post(body).build()
            try {
                client.newCall(req).execute().use { resp ->
                    when {
                        resp.isSuccessful -> {
                            val token = json.decodeFromString<TokenResponse>(resp.body!!.string()).token
                            LoginResult.Success(token)
                        }
                        resp.code == 401 -> LoginResult.InvalidCredentials
                        resp.code == 429 -> LoginResult.RateLimited
                        else -> LoginResult.Error("HTTP ${resp.code}")
                    }
                }
            } catch (e: IOException) {
                LoginResult.Error(e.message ?: "network error")
            }
        }

    /** GET /api/v1/me — resolve the user's configured default model (best-effort, null on any failure). */
    suspend fun fetchMe(baseUrl: String, token: String): MeResponse? =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/me")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) return@use null
                    json.decodeFromString<MeResponse>(resp.body!!.string())
                }
            } catch (e: Exception) {
                null
            }
        }

    suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/me")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) return@use null
                    json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
                }
            } catch (e: Exception) {
                null
            }
        }

    /** GET /api/v1/models — the full model catalog for the picker. */
    suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/models")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        val reason = "models: HTTP ${resp.code}" + when (resp.code) {
                            401 -> " (Token ungültig)"
                            403 -> " (Zugriff verweigert)"
                            else -> ""
                        }
                        Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
                        return@use FetchResult.Fail(reason)
                    }
                    FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
                }
            } catch (e: Exception) {
                Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}")
                FetchResult.Fail(diagnoseFetchError("models", e))
            }
        }

    /** GET /api/v1/conversations — the sidebar list. */
    suspend fun fetchConversations(baseUrl: String, token: String): FetchResult<List<ConversationSummary>> =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        val reason = "conversations: HTTP ${resp.code}" + when (resp.code) {
                            401 -> " (Token ungültig)"
                            403 -> " (Zugriff verweigert)"
                            else -> ""
                        }
                        Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
                        return@use FetchResult.Fail(reason)
                    }
                    FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
                }
            } catch (e: Exception) {
                Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}")
                FetchResult.Fail(diagnoseFetchError("conversations", e))
            }
        }

    /** POST /api/v1/conversations — create one, returns its id (null on failure). */
    suspend fun createConversation(baseUrl: String, token: String, title: String? = null): String? =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(CreateConvoRequest(title)).toRequestBody(jsonMedia)
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations")
                .post(body)
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        Log.w(TAG, "createConversation: HTTP ${resp.code}")
                        return@use null
                    }
                    json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
                }
            } catch (e: Exception) {
                Log.w(TAG, "createConversation: ${e.javaClass.simpleName}: ${e.message}")
                null
            }
        }

    /** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */
    suspend fun fetchMessages(baseUrl: String, token: String, id: String): List<StoredMessage> =
        fetchConversationData(baseUrl, token, id).messages

    suspend fun fetchConversationData(baseUrl: String, token: String, id: String): ConversationData =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}")
                        return@use ConversationData()
                    }
                    val detail = json.decodeFromString<ConversationDetail>(resp.body!!.string())
                    ConversationData(detail.messages, detail.activeRootId, detail.activeChild)
                }
            } catch (e: Exception) {
                Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
                ConversationData()
            }
        }

    /** POST /api/v1/conversations/[id]/messages — persist an exchange. Returns success. */
    suspend fun saveMessages(baseUrl: String, token: String, id: String, messages: List<SaveMessage>): Boolean =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(SaveMessagesRequest(messages)).toRequestBody(jsonMedia)
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id/messages")
                .post(body)
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) Log.w(TAG, "saveMessages($id): HTTP ${resp.code}")
                    resp.isSuccessful
                }
            } catch (e: Exception) {
                Log.w(TAG, "saveMessages($id): ${e.javaClass.simpleName}: ${e.message}")
                false
            }
        }

    /** PATCH /api/v1/conversations/[id] — update title, locked, pinned, activeRootId, activeChild. */
    suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
        withContext(Dispatchers.IO) {
            fun toJsonElement(v: Any?): kotlinx.serialization.json.JsonElement = when (v) {
                is String -> kotlinx.serialization.json.JsonPrimitive(v)
                is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
                is Map<*, *> -> kotlinx.serialization.json.JsonObject(
                    v.entries.associate { (k, v2) -> k.toString() to toJsonElement(v2) }
                )
                null -> kotlinx.serialization.json.JsonNull
                else -> kotlinx.serialization.json.JsonNull
            }
            val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
                body.mapValues { (_, v) -> toJsonElement(v) }
            )).toRequestBody(jsonMedia)
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id")
                .patch(payload)
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { it.isSuccessful }
            } catch (e: Exception) {
                Log.w(TAG, "patchConversation($id): ${e.javaClass.simpleName}: ${e.message}")
                false
            }
        }

    /** DELETE /api/v1/conversations/[id] — permanently remove a conversation. */
    suspend fun deleteConversation(baseUrl: String, token: String, id: String): Boolean =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id")
                .delete()
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { it.isSuccessful }
            } catch (e: Exception) {
                Log.w(TAG, "deleteConversation($id): ${e.javaClass.simpleName}: ${e.message}")
                false
            }
        }

    /** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
    suspend fun requestUnlock(baseUrl: String, token: String, password: String? = null): String? =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(UnlockRequest(password))
            val req = Request.Builder()
                .url("$baseUrl/api/v1/auth/unlock")
                .post(body.toRequestBody(jsonMedia))
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        Log.w(TAG, "requestUnlock: HTTP ${resp.code}")
                        return@use null
                    }
                    json.decodeFromString<UnlockResponse>(resp.body!!.string()).unlockToken
                }
            } catch (e: Exception) {
                Log.w(TAG, "requestUnlock: ${e.javaClass.simpleName}: ${e.message}")
                null
            }
        }

    /** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
    suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
        fetchLockedConversationData(baseUrl, token, id, unlockToken).messages

    suspend fun fetchLockedConversationData(baseUrl: String, token: String, id: String, unlockToken: String): ConversationData =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id")
                .header("Authorization", "Bearer $token")
                .header("X-Unlock-Token", unlockToken)
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
                        return@use ConversationData()
                    }
                    val detail = json.decodeFromString<ConversationDetail>(resp.body!!.string())
                    ConversationData(detail.messages, detail.activeRootId, detail.activeChild)
                }
            } catch (e: Exception) {
                Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
                ConversationData()
            }
        }

    /** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
    suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
        withContext(Dispatchers.IO) {
            val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$conversationId/messages")
                .delete(body)
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { it.isSuccessful }
            } catch (e: Exception) {
                Log.w(TAG, "deleteMessage: ${e.javaClass.simpleName}: ${e.message}")
                false
            }
        }

    /** POST /api/v1/conversations/[id]/title — returns the generated title or null. */
    suspend fun generateTitle(baseUrl: String, token: String, id: String, manual: Boolean = false): String? =
        withContext(Dispatchers.IO) {
            val body = if (manual) """{"manual":true}""" else "{}"
            val req = Request.Builder()
                .url("$baseUrl/api/v1/conversations/$id/title")
                .post(body.toRequestBody(jsonMedia))
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) return@use null
                    val body = resp.body?.string() ?: return@use null
                    json.decodeFromString<TitleResponse>(body).title
                }
            } catch (e: Exception) {
                null
            }
        }

    /**
     * POST /api/v1/chat — streams the assistant reply. Emits the cumulative *visible*
     * text after each network chunk (sentinels stripped), so the caller just assigns
     * it to the message content.
     *
     * Uses [InputStreamReader] for correct UTF-8 multi-byte boundary handling and
     * [StreamConsumer.Incremental] for O(chunk-size) parsing — each character is
     * visited exactly once across the entire stream, avoiding the O(N²) cost of
     * re-decoding and re-scanning the full buffer on every chunk.
     *
     * [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields.
     */
    fun chat(
        baseUrl: String,
        token: String,
        model: String,
        messages: List<WireMessage>,
        reasoningEffort: String? = null,
        preferThroughput: Boolean = false,
        attachments: List<Attachment>? = null,
        webSearch: Boolean = false,
        webSearchProvider: String? = null,
        temperature: Float? = null,
        topP: Float? = null,
        topK: Int? = null,
    ): Flow<StreamState> = flow {
        val payload = json.encodeToString(
            ChatRequest(
                model = model,
                messages = messages,
                reasoningEffort = reasoningEffort,
                preferThroughput = if (preferThroughput) true else null,
                attachments = attachments?.takeIf { it.isNotEmpty() },
                webSearch = if (webSearch) true else null,
                webSearchProvider = if (webSearch) webSearchProvider else null,
                temperature = temperature,
                topP = topP,
                topK = topK,
            ),
        ).toRequestBody(jsonMedia)
        val req = Request.Builder()
            .url("$baseUrl/api/v1/chat")
            .post(payload)
            .header("Authorization", "Bearer $token")
            .build()

        val resp = client.newCall(req).execute()
        if (!resp.isSuccessful) {
            val code = resp.code
            resp.close()
            throw ChatHttpException(code)
        }

        resp.body!!.byteStream().use { input ->
            val reader = InputStreamReader(input, Charsets.UTF_8)
            val consumer = StreamConsumer.Incremental()
            val charBuf = CharArray(4096)
            while (true) {
                val n = reader.read(charBuf)
                if (n == -1) break
                emit(consumer.append(charBuf, 0, n))
            }
        }
    }.flowOn(Dispatchers.IO)

    /**
     * Warm the OkHttp HTTP/2 connection pool against [baseUrl]. Fire-and-forget
     * GET to the unauth `/api/v1/meta` endpoint — eliminates the cold TCP/TLS
     * handshake (~150-300ms on mobile) from the first real request after app
     * start or background resume.
     */
    /** POST /api/v1/upload — multipart file upload, returns the created Attachment. */
    suspend fun uploadFile(
        baseUrl: String,
        token: String,
        fileName: String,
        mimeType: String,
        bytes: ByteArray,
    ): FetchResult<Attachment> = withContext(Dispatchers.IO) {
        val body = MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart(
                "file", fileName,
                bytes.toRequestBody(mimeType.toMediaType()),
            )
            .build()
        val req = Request.Builder()
            .url("$baseUrl/api/v1/upload")
            .post(body)
            .header("Authorization", "Bearer $token")
            .build()
        try {
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) {
                    val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
                        401 -> " (Token ungültig)"
                        413 -> " (Datei zu groß)"
                        else -> ""
                    }
                    Log.w(TAG, "uploadFile: HTTP ${resp.code}")
                    return@use FetchResult.Fail(reason)
                }
                val raw = resp.body!!.string()
                FetchResult.Ok(json.decodeFromString<Attachment>(raw))
            }
        } catch (e: Exception) {
            Log.w(TAG, "uploadFile: ${e.javaClass.simpleName}: ${e.message}")
            FetchResult.Fail(diagnoseFetchError("upload", e))
        }
    }

    @Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
    @Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)

    /** POST /api/v1/audio-gen — generate speech from text via Gemini-TTS. Returns the audio URL or null. */
    suspend fun generateSpeech(baseUrl: String, token: String, text: String, voice: String? = null): String? =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(SpeechRequest(
                prompt = text.take(2000),
                voice = voice,
            )).toRequestBody(jsonMedia)
            val req = Request.Builder()
                .url("$baseUrl/api/v1/audio-gen")
                .post(body)
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        Log.w(TAG, "generateSpeech: HTTP ${resp.code}")
                        return@use null
                    }
                    json.decodeFromString<SpeechResponse>(resp.body!!.string()).url.takeIf { it.isNotBlank() }
                }
            } catch (e: Exception) {
                Log.w(TAG, "generateSpeech: ${e.javaClass.simpleName}: ${e.message}")
                null
            }
        }

    suspend fun fetchMeta(baseUrl: String): MetaResponse? = withContext(Dispatchers.IO) {
        val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
        try {
            client.newCall(req).execute().use { resp ->
                if (!resp.isSuccessful) return@use null
                json.decodeFromString<MetaResponse>(resp.body!!.string())
            }
        } catch (e: Exception) {
            Log.w(TAG, "fetchMeta: ${e.javaClass.simpleName}: ${e.message}")
            null
        }
    }

    suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
        try {
            val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
            client.newCall(req).execute().close()
        } catch (_: Exception) {
            // best-effort — a failed prewarm just means the first real request pays the cold cost
        }
    }

    /** GET /api/v1/auth/tokens — list the user's app devices/tokens. */
    suspend fun fetchDevices(baseUrl: String, token: String): List<AppDevice> =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/auth/tokens")
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) return@use emptyList()
                    json.decodeFromString<AppDevicesResponse>(resp.body!!.string()).tokens
                }
            } catch (e: Exception) {
                Log.w(TAG, "fetchDevices: ${e.javaClass.simpleName}: ${e.message}")
                emptyList()
            }
        }

    /** DELETE /api/v1/auth/tokens/[id] — revoke a device token (remote logout). */
    suspend fun revokeDevice(baseUrl: String, token: String, deviceId: String): Boolean =
        withContext(Dispatchers.IO) {
            val req = Request.Builder()
                .url("$baseUrl/api/v1/auth/tokens/$deviceId")
                .delete()
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { it.isSuccessful }
            } catch (e: Exception) {
                Log.w(TAG, "revokeDevice: ${e.javaClass.simpleName}: ${e.message}")
                false
            }
        }

    /** POST /api/v1/auth/change-password — change the user's password. */
    suspend fun changePassword(baseUrl: String, token: String, currentPassword: String, newPassword: String): FetchResult<Unit> =
        withContext(Dispatchers.IO) {
            val body = json.encodeToString(ChangePasswordRequest(currentPassword, newPassword))
            val req = Request.Builder()
                .url("$baseUrl/api/v1/auth/change-password")
                .post(body.toRequestBody(jsonMedia))
                .header("Authorization", "Bearer $token")
                .build()
            try {
                client.newCall(req).execute().use { resp ->
                    if (resp.isSuccessful) FetchResult.Ok(Unit)
                    else {
                        val errBody = resp.body?.string() ?: ""
                        val error = try { json.decodeFromString<ErrorBody>(errBody).error } catch (_: Exception) { "unknown" }
                        FetchResult.Fail(error)
                    }
                }
            } catch (e: Exception) {
                FetchResult.Fail(e.message ?: "network_error")
            }
        }
}

@Serializable data class MetaResponse(
    val app: String = "",
    val apiVersion: Int = 0,
    val features: List<String> = emptyList(),
)

@Serializable private data class ErrorBody(val error: String = "unknown")
