package wang.yaojia.webterm.api.enroll

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.Instant

/**
 * B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
 * short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
 * NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
 */
public data class LoginResult(
    val enrollToken: String,
    val accountId: String,
    val expiresInSeconds: Long,
)

/**
 * B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
 * plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
 * in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
 *
 * NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
 * reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
 */
public data class EnrollmentResult(
    val deviceId: String,
    /** Leaf certificate DER (decoded from the response's base64). */
    val certificate: ByteArray,
    /** Issuer chain DERs (device-CA etc.), leaf excluded. */
    val caChain: List<ByteArray>,
    val notBefore: Instant?,
    val notAfter: Instant?,
    /** When to renew from the same hardware key (~2/3 of the lifetime). */
    val renewAfter: Instant?,
) {
    /**
     * The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
     * triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
     */
    public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
        val due = renewAfter ?: return false
        return !now.isBefore(due) // now >= renewAfter
    }
}

/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
    /**
     * A non-success HTTP status with the server's uniform `{ error }` code, if any (401
     * missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
     * CSR/subdomain). Never leaks the response body.
     */
    public data class Http(val status: Int, val code: String?) :
        DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))

    /** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
    public data object MalformedResponse :
        DeviceEnrollmentError("device enrollment response was not the expected shape")

    /** A required request field was empty — rejected client-side BEFORE any network I/O. */
    public data object InvalidRequest :
        DeviceEnrollmentError("device enrollment request was missing a required field")
}

// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────

/**
 * ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
 * `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
 * (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
 */
internal val EnrollJson: Json = Json {
    encodeDefaults = false
    explicitNulls = false
    ignoreUnknownKeys = true
    isLenient = true
}

@Serializable
internal data class LoginRequestBody(val password: String)

@Serializable
internal data class EnrollRequestBody(
    val csr: String,
    val keyAlg: String,
    val subdomain: String,
    val deviceName: String,
    val attestation: String? = null,
)

/**
 * The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
 * and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
 * enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
 */
@Serializable
internal data class RenewRequestBody(val csr: String)

@Serializable
internal data class LoginResponseDto(
    val enrollToken: String,
    val accountId: String,
    val expiresIn: Long,
)

@Serializable
internal data class EnrollResponseDto(
    val deviceId: String,
    val cert: String,
    val caChain: List<String> = emptyList(),
    val notBefore: String? = null,
    val notAfter: String? = null,
    val renewAfter: String? = null,
)

@Serializable
internal data class ErrorDto(val error: String? = null)

/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
internal fun parseInstantOrNull(text: String?): Instant? {
    if (text == null) return null
    return runCatching { Instant.parse(text) }.getOrNull()
}
