package dev.privacyllc.period.core.security

// java.util.Base64, not android.util.Base64, and it is not a style choice: the
// Android one is a stub on the host JVM, so using it would push every test of
// the record format onto an emulator. java.util is available from API 26, which
// is this project's minSdk exactly.
import java.util.Base64

/**
 * What is stored so a PIN can be checked, and deliberately not the PIN.
 *
 * `v1 | iterations | salt | mac`, each part Base64 where it is bytes.
 *
 * ## Why the version and the iteration count ship from the first commit
 *
 * Neither is needed today and both are impossible to add later. Raising the
 * PBKDF2 cost means re-deriving from the PIN, and the only instant the PIN
 * exists in memory is immediately after a successful unlock — so a record with
 * no iteration field can never be told apart from one at the new cost, and
 * under the no-recovery policy there is no "ask them to set it again" fallback
 * to fall back to. A discriminator that costs six characters now cannot be
 * retrofitted at any price.
 *
 * ## What an attacker holding this file gets
 *
 * Nothing usable without the device. The MAC is taken with a key that lives in
 * the Android Keystore and cannot be exported, so a six-digit PIN — trivially
 * brute-forced against a plain hash — cannot be attacked offline at all. The
 * PBKDF2 layer underneath is defence for the case where that assumption fails.
 */
internal data class VerifierRecord(
    val iterations: Int,
    val salt: ByteArray,
    val mac: ByteArray,
) {
    fun encode(): String = listOf(
        VERSION,
        iterations.toString(),
        salt.b64(),
        mac.b64(),
    ).joinToString(SEPARATOR)

    // Generated equals/hashCode would compare the arrays by identity, which
    // makes two identical records unequal and is the classic data-class-with-
    // ByteArray trap. Tests compare records; they must compare contents.
    override fun equals(other: Any?): Boolean =
        this === other || (
            other is VerifierRecord &&
                iterations == other.iterations &&
                salt.contentEquals(other.salt) &&
                mac.contentEquals(other.mac)
            )

    override fun hashCode(): Int =
        (iterations * 31 + salt.contentHashCode()) * 31 + mac.contentHashCode()

    /**
     * Never renders the salt or the MAC.
     *
     * Same rule as `PeriodRecord` and `Prediction` in the `domain` modules: a
     * data class prints its own contents into any exception message or crash
     * payload that
     * interpolates it, and nobody has to write a logging call for that to
     * happen. `checkNoHealthLogging` cannot see it either.
     */
    override fun toString(): String = "VerifierRecord(v=$VERSION, iterations=$iterations)"

    companion object {
        const val VERSION = "v1"
        private const val SEPARATOR = "|"
        private const val PARTS = 4

        /** Null for anything unparseable — a corrupt record is "no PIN set", never a crash. */
        fun decode(raw: String?): VerifierRecord? {
            val parts = raw?.split(SEPARATOR) ?: return null
            if (parts.size != PARTS || parts[0] != VERSION) return null
            val iterations = parts[1].toIntOrNull()?.takeIf { it > 0 } ?: return null
            val salt = parts[2].fromB64() ?: return null
            val mac = parts[3].fromB64() ?: return null
            if (salt.isEmpty() || mac.isEmpty()) return null
            return VerifierRecord(iterations, salt, mac)
        }

        private fun ByteArray.b64(): String = Base64.getEncoder().withoutPadding().encodeToString(this)

        private fun String.fromB64(): ByteArray? =
            runCatching { Base64.getDecoder().decode(this) }.getOrNull()
    }
}
