package wang.yaojia.webterm.push

import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID

/**
 * A30 JVM contract (plan §5 A30 / §8): the pure `cls`→notification mapping, content minimization,
 * the decision-POST payload shape via a fake `ApiClient`, and the token-never-logged discipline.
 * BiometricPrompt / notification rendering / FCM delivery are device QA (§7).
 */
class PushDecisionTest {

    private val sessionId: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
    private val token = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
    private val endpoint: HostEndpoint = HostEndpoint.fromBaseUrl("http://192.168.1.10:3000")!!
    private val decisionUrl = "http://192.168.1.10:3000/hook/decision"

    // ── NotificationBuilder.plan: cls → actions mapping ─────────────────────────────────────────

    @Test
    fun `needs-input maps to a high-importance gate with Allow and Deny actions`() {
        val plan = NotificationBuilder.plan(PushPayload.CLASS_NEEDS_INPUT)!!
        assertEquals(listOf(NotificationBuilder.Action.ALLOW, NotificationBuilder.Action.DENY), plan.actions)
        assertTrue(plan.highImportance)
        assertEquals(NotificationBuilder.CHANNEL_GATE, plan.channelId)
    }

    @Test
    fun `done maps to a default-importance informational card with no actions`() {
        val plan = NotificationBuilder.plan(PushPayload.CLASS_DONE)!!
        assertTrue(plan.actions.isEmpty())
        assertFalse(plan.highImportance)
        assertEquals(NotificationBuilder.CHANNEL_DONE, plan.channelId)
    }

    @Test
    fun `an unknown class produces no notification`() {
        assertNull(NotificationBuilder.plan("stuck"))
        assertNull(NotificationBuilder.plan(""))
    }

    // ── Content minimization: no cwd/command/terminal bytes, no token in visible text ───────────

    @Test
    fun `visible notification text never leaks the token, cwd, command, or terminal bytes`() {
        val leaks = listOf(token, "/home/user/secret-project", "rm -rf /", "[0m", sessionId.toString())
        for (cls in listOf(PushPayload.CLASS_NEEDS_INPUT, PushPayload.CLASS_DONE)) {
            val plan = NotificationBuilder.plan(cls)!!
            val visible = plan.title + "\n" + plan.text
            for (secret in leaks) {
                assertFalse(visible.contains(secret), "cls=$cls visible text leaked: $secret")
            }
        }
    }

    // ── Decision-POST payload shape via a fake ApiClient ────────────────────────────────────────

    @Test
    fun `deny posts sessionId+decision+token with the guarded Origin header`() = runTest {
        val http = FakeHttpTransport()
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
        val submitter = singleHostSubmitter(http)

        val accepted = submitter.submit(sessionId, HookDecision.DENY, token)

        assertTrue(accepted)
        val request = http.recordedRequests.single()
        assertEquals(HttpMethod.POST, request.method)
        assertEquals(decisionUrl, request.url)
        assertEquals(endpoint.originHeader, request.headers["Origin"])
        val body = String(request.body!!, Charsets.UTF_8)
        assertTrue(body.contains("\"sessionId\":\"$sessionId\""), body)
        assertTrue(body.contains("\"decision\":\"deny\""), body)
        assertTrue(body.contains("\"token\":\"$token\""), body)
    }

    @Test
    fun `allow posts decision=allow with the token`() = runTest {
        val http = FakeHttpTransport()
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)

        val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)

        assertTrue(accepted)
        val body = String(http.recordedRequests.single().body!!, Charsets.UTF_8)
        assertTrue(body.contains("\"decision\":\"allow\""), body)
        assertTrue(body.contains("\"token\":\"$token\""), body)
    }

    @Test
    fun `a 403 (stale or used token, idempotency) is swallowed to a non-accept, never a throw`() = runTest {
        val http = FakeHttpTransport()
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)

        val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)

        assertFalse(accepted)
    }

    @Test
    fun `no paired host means no POST and a non-accept`() = runTest {
        val http = FakeHttpTransport()
        val submitter = PushDecisionSubmitter(
            loadEndpoints = { emptyList() },
            clientFor = { ApiClient(it, http) },
        )

        assertFalse(submitter.submit(sessionId, HookDecision.DENY, token))
        assertTrue(http.recordedRequests.isEmpty())
    }

    // ── Multi-host: the payload carries no host, so try each until one accepts ───────────────────

    @Test
    fun `iterates past a rejecting host to the host that issued the token`() = runTest {
        val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
        val http = FakeHttpTransport()
        http.queueSuccess(HttpMethod.POST, "http://10.0.0.5:3000/hook/decision", status = 403) // wrong host
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // issuing host
        val submitter = PushDecisionSubmitter(
            loadEndpoints = { listOf(other, endpoint) },
            clientFor = { ApiClient(it, http) },
        )

        assertTrue(submitter.submit(sessionId, HookDecision.ALLOW, token))
        assertEquals(2, http.recordedRequests.size)
    }

    @Test
    fun `stops at the first accepting host (no token blasted to later hosts)`() = runTest {
        val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
        val http = FakeHttpTransport()
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // first host accepts
        val submitter = PushDecisionSubmitter(
            loadEndpoints = { listOf(endpoint, other) },
            clientFor = { ApiClient(it, http) },
        )

        assertTrue(submitter.submit(sessionId, HookDecision.DENY, token))
        assertEquals(1, http.recordedRequests.size)
        assertEquals(decisionUrl, http.recordedRequests.single().url) // second host never contacted
    }

    // ── Token discipline: never handed to the diagnostics/log seam ──────────────────────────────

    @Test
    fun `the diagnostics seam never receives the token across accept, reject, and error paths`() = runTest {
        val logged = mutableListOf<String>()
        val http = FakeHttpTransport()
        // accept, reject, then no-queued-response (error) — exercise every diagnostics branch.
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
        http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)
        val submitter = PushDecisionSubmitter(
            loadEndpoints = { listOf(endpoint) },
            clientFor = { ApiClient(it, http) },
            diagnostics = { logged.add(it) },
        )

        submitter.submit(sessionId, HookDecision.ALLOW, token) // accepted
        submitter.submit(sessionId, HookDecision.DENY, token) // rejected by the only host
        submitter.submit(sessionId, HookDecision.DENY, token) // transport error (no queued response)

        assertTrue(logged.isNotEmpty())
        for (line in logged) {
            assertFalse(line.contains(token), "diagnostics leaked the token: $line")
        }
    }

    // ── PushPayload boundary validation ─────────────────────────────────────────────────────────

    @Test
    fun `parse keeps the gate token and marks it actionable`() {
        val payload = PushPayload.parse(
            mapOf(
                PushPayload.KEY_SESSION_ID to sessionId.toString(),
                PushPayload.KEY_CLS to PushPayload.CLASS_NEEDS_INPUT,
                PushPayload.KEY_TOKEN to token,
            ),
        )!!
        assertEquals(sessionId, payload.sessionId)
        assertEquals(token, payload.token)
        assertTrue(payload.isActionableGate)
    }

    @Test
    fun `parse of a done signal has no token and is not an actionable gate`() {
        val payload = PushPayload.parse(
            mapOf(
                PushPayload.KEY_SESSION_ID to sessionId.toString(),
                PushPayload.KEY_CLS to PushPayload.CLASS_DONE,
            ),
        )!!
        assertNull(payload.token)
        assertFalse(payload.isActionableGate)
    }

    @Test
    fun `parse rejects a missing or non-UUID sessionId and a missing cls`() {
        assertNull(PushPayload.parse(mapOf(PushPayload.KEY_CLS to PushPayload.CLASS_DONE)))
        assertNull(
            PushPayload.parse(
                mapOf(PushPayload.KEY_SESSION_ID to "not-a-uuid", PushPayload.KEY_CLS to PushPayload.CLASS_DONE),
            ),
        )
        assertNull(PushPayload.parse(mapOf(PushPayload.KEY_SESSION_ID to sessionId.toString())))
    }

    private fun singleHostSubmitter(http: FakeHttpTransport): PushDecisionSubmitter =
        PushDecisionSubmitter(
            loadEndpoints = { listOf(endpoint) },
            clientFor = { ApiClient(it, http) },
        )
}
