package wang.yaojia.webterm.api.enroll

import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport

/**
 * B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
 * same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
 * app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
 * step (B4 pinned contract):
 *
 *   `POST /auth/login`            `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
 *   `POST /device/enroll`         [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
 *                                   deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
 *                                   notBefore, notAfter, renewAfter }`
 *   `POST /device/:id/renew`      [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
 *                                   server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
 *
 * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
 * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
 * are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
 *
 * Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
 * per-call and never held/logged (leaked-bearer blast radius).
 */
public class DeviceEnrollmentClient(
    baseUrl: String,
    private val http: HttpTransport,
) {
    /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
    private val base: String = baseUrl.trim().trimEnd('/')

    /**
     * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
     * rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
     */
    public suspend fun login(password: String): LoginResult {
        if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
        val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
        val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
        val dto = decodeOn201(response, LoginResponseDto.serializer())
        return LoginResult(
            enrollToken = dto.enrollToken,
            accountId = dto.accountId,
            expiresInSeconds = dto.expiresIn,
        )
    }

    /**
     * Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
     * receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
     */
    public suspend fun enroll(
        bearerToken: String,
        csrDer: ByteArray,
        subdomain: String,
        deviceName: String,
        attestation: String? = null,
    ): EnrollmentResult {
        if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
            throw DeviceEnrollmentError.InvalidRequest
        }
        val body = EnrollJson.encodeToString(
            EnrollRequestBody.serializer(),
            EnrollRequestBody(
                csr = base64(csrDer),
                keyAlg = KEY_ALG_EC_P256,
                subdomain = subdomain,
                deviceName = deviceName,
                attestation = attestation,
            ),
        )
        val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
        return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
    }

    /**
     * Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
     *
     * The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
     * `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
     * is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
     * null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
     * production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
     */
    public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
        if (deviceId.isEmpty() || csrDer.isEmpty()) {
            throw DeviceEnrollmentError.InvalidRequest
        }
        // Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
        // and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
        // rejected. Identity/key come from the current cert + registry record, never the body.
        val body = EnrollJson.encodeToString(
            RenewRequestBody.serializer(),
            RenewRequestBody(csr = base64(csrDer)),
        )
        val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
        val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
        return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
    }

    // ── Request/response plumbing ────────────────────────────────────────────────────────────

    private fun jsonRequest(
        method: HttpMethod,
        path: String,
        jsonBody: ByteArray,
        bearer: String?,
    ): HttpRequest {
        val headers = LinkedHashMap<String, String>()
        headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
        // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
        // string must never emit a bare "Bearer " header.
        if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
        return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
    }

    /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
     *  code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
    private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
        if (response.status != HTTP_CREATED) {
            throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
        }
        return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
            .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
    }

    private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
        val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
        val chain = dto.caChain.map { entry ->
            decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
        }
        return EnrollmentResult(
            deviceId = dto.deviceId,
            certificate = certificate,
            caChain = chain,
            notBefore = parseInstantOrNull(dto.notBefore),
            notAfter = parseInstantOrNull(dto.notAfter),
            renewAfter = parseInstantOrNull(dto.renewAfter),
        )
    }

    private fun errorCode(body: ByteArray): String? =
        runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()

    private companion object {
        const val PATH_LOGIN = "/auth/login"
        const val PATH_ENROLL = "/device/enroll"
        const val PATH_DEVICE = "/device"
        const val KEY_ALG_EC_P256 = "ec-p256"
        const val HTTP_CREATED = 201

        const val HEADER_CONTENT_TYPE = "Content-Type"
        const val HEADER_AUTHORIZATION = "Authorization"
        const val CONTENT_TYPE_JSON = "application/json"
        const val BEARER_PREFIX = "Bearer "

        private val BASE64_ENCODER = java.util.Base64.getEncoder()
        private val BASE64_DECODER = java.util.Base64.getDecoder()

        fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)

        fun decodeBase64OrNull(text: String): ByteArray? =
            runCatching { BASE64_DECODER.decode(text) }.getOrNull()

        /** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
         *  server-minted UUIDs, but never build a URL from an unescaped field). */
        fun encodePathSegment(value: String): String {
            val sb = StringBuilder()
            for (byte in value.encodeToByteArray()) {
                val code = byte.toInt() and 0xFF
                val ch = code.toChar()
                if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
            }
            return sb.toString()
        }

        private val UNRESERVED: Set<Char> =
            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
        private val HEX = "0123456789ABCDEF".toCharArray()
    }
}
