/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.data.repository

import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response

/**
 * [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
 * fake [MeApi]. The interesting case is the `409` cap: the device list must survive
 * into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
 */
class AccountTrustedDevicesTest {

    private val json = Json {
        ignoreUnknownKeys = true
        explicitNulls = false
        coerceInputValues = true
    }

    /** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
    private open class FakeMeApi(
        var trustResponse: Response<TrustDeviceResultDto>? = null,
        var devices: List<TrustedDeviceDto> = emptyList(),
        var revokeFlag: Boolean = true,
        var revokeCount: Int = 0,
        var remaining: Int = 0,
        var generated: List<String> = emptyList(),
    ) : MeApi {
        override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
        override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
            trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
        override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
        override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
        override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
        override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
            RecoveryCodesDto(generated)

        // Unused by these tests.
        override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
        override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
        override suspend fun changePassword(body: ChangePasswordRequest) = Unit
        override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
        override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
        override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
        override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
        override suspend fun unlinkIdentity(provider: String) = Unit
    }

    private fun repo(api: MeApi) = AccountRepository(api, json)

    @Test fun trustThisDeviceReturnsToken() = runTest {
        val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
        val outcome = repo(api).trustThisDevice("Pixel")
        assertTrue(outcome is TrustOutcome.Trusted)
        assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
    }

    @Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
        val body = """{"error":"trusted_device_limit","devices":[
            {"id":1,"platform":"web","deviceName":"Firefox"},
            {"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
            .toResponseBody("application/json".toMediaTypeOrNull())
        val api = FakeMeApi(trustResponse = Response.error(409, body))
        val outcome = repo(api).trustThisDevice(null)
        assertTrue(outcome is TrustOutcome.LimitReached)
        val devices = (outcome as TrustOutcome.LimitReached).devices
        assertEquals(2, devices.size)
        assertEquals("Pixel", devices[1].deviceName)
    }

    @Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
        val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
        val api = FakeMeApi(trustResponse = Response.error(500, body))
        assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
    }

    @Test fun revokeMapsFlagAndCount() = runTest {
        val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
        assertTrue(revoked is ApiResult.Ok && revoked.data)

        val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
        assertTrue(all is ApiResult.Ok && all.data == 3)
    }

    @Test fun recoveryStatusAndGenerateMap() = runTest {
        val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
        assertTrue(status is ApiResult.Ok && status.data.remaining == 6)

        val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
        assertTrue(gen is ApiResult.Ok)
        assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
    }
}
