package dev.privacyllc.period.core.notifications

import android.app.Notification
import android.app.NotificationManager
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import androidx.test.platform.app.InstrumentationRegistry
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith

/**
 * What a locked phone would actually show, checked on a real device.
 *
 * The unit tests prove the *copy* is right. This proves the copy reaches Android
 * intact — that the public version is attached, that the visibility flag is set,
 * and that the thing a stranger could read is the public one.
 *
 * Those are different claims, and only the second one catches the mistake that
 * matters: a notification marked private with no public version does not show a
 * blank lock screen, it shows the private text.
 */
@RunWith(AndroidJUnit4::class)
class NotificationPrivacyTest {

    /**
     * The test APK is its own package, so the app's grant does not cover it.
     * Granted here rather than with `adb shell pm grant` out of band, because a
     * test that only passes after a manual command is a test nobody can re-run.
     *
     * **Conditional, and it has to be.** `POST_NOTIFICATIONS` arrived in API 33.
     * On anything older the permission does not exist, so `GrantPermissionRule`
     * fails with "Failed to grant permissions" *before the first assertion runs*
     * — and every test in this class errors out with a message that says nothing
     * about notifications.
     *
     * That is how this file came to be un-runnable on `PeriodMinSdk26` without
     * anybody noticing: it is the only emulator where it fails, it fails for a
     * reason unrelated to what it tests, and a green run on a modern image looks
     * like a green run. The class guards what a **locked screen** shows, which
     * is the disclosure this product is most careful about, so "passes on the
     * newest device" was never the claim worth having.
     *
     * Below 33 the permission is not required to post at all, so a no-op rule is
     * correct rather than a workaround.
     */
    @get:org.junit.Rule
    val permission: TestRule =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS)
        } else {
            TestRule { base, _ -> base }
        }

    private val context = InstrumentationRegistry.getInstrumentation().targetContext
    private val manager = context.getSystemService(NotificationManager::class.java)
    private val notifier = PeriodNotifier(context)

    @Before fun clear() = manager.cancelAll()

    @After fun tearDown() = manager.cancelAll()

    /**
     * Posting is asynchronous. Polled rather than read once — a bare read
     * failed intermittently and the failure looked like "nothing was posted",
     * which is the same symptom as a missing permission and sent me looking in
     * the wrong place.
     */
    private fun postedNotification(): Notification? {
        repeat(50) {
            manager.activeNotifications
                .firstOrNull { it.id == PeriodNotifier.NOTIFICATION_ID }
                ?.let { return it.notification }
            Thread.sleep(20)
        }
        return null
    }

    private fun Notification.textOf(): String {
        val e = extras
        return listOfNotNull(
            e.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
            e.getCharSequence(Notification.EXTRA_TEXT)?.toString(),
        ).joinToString(" ").lowercase()
    }

    @Test
    fun everyPrivateModeAttachesAPublicVersionThatLeaksNothing() {
        listOf(NotificationPrivacy.DISCREET, NotificationPrivacy.MAXIMUM_PRIVACY).forEach { mode ->
            ReminderKind.entries.forEach { kind ->
                manager.cancelAll()

                val text = NotificationCopy.textFor(kind, mode, daysUntil = 2)
                val posted = notifier.notify(text, mode, contentIntent = null)
                assertTrue("nothing was posted for $kind/$mode — is the permission granted?", posted)

                val notification = postedNotification()
                assertNotNull("$kind/$mode was not delivered", notification)

                // The whole point: a private notification with no public version
                // falls back to showing its private text on the lock screen.
                val public = notification!!.publicVersion
                assertNotNull("$kind/$mode attached no public version", public)

                assertEquals(
                    "$kind/$mode was not marked private",
                    Notification.VISIBILITY_PRIVATE,
                    notification.visibility,
                )

                val visible = public!!.textOf()
                NotificationCopy.SENSITIVE_WORDS.forEach { word ->
                    assertTrue(
                        "$kind/$mode would have shown \"$word\" on a lock screen: \"$visible\"",
                        !visible.contains(word),
                    )
                }
            }
        }
    }

    /**
     * Both builders carry the app's own mark, not a framework asset.
     *
     * Android masks a small icon to a white silhouette taken from its alpha
     * channel, so the old `android.R.drawable.ic_dialog_info` reached the status
     * bar as *that* asset's outline — a generic glyph, on every reminder this
     * app has ever sent.
     *
     * Asserted on the posted `Notification` rather than by reading the source,
     * and on **both** versions: the public one is what a locked screen renders,
     * which is the surface this product is most careful about, and it is built
     * by a separate call that is easy to update by half.
     */
    @Test
    fun bothTheLockScreenAndTheShadeShowThisAppsOwnMark() {
        val expected = R.drawable.ic_notification

        NotificationPrivacy.entries.forEach { mode ->
            manager.cancelAll()
            val text = NotificationCopy.textFor(
                ReminderKind.PERIOD_APPROACHING, mode, daysUntil = 2,
            )
            notifier.notify(text, mode, contentIntent = null)

            val posted = postedNotification()
            assertNotNull("nothing was posted for $mode", posted)

            // `Notification.icon` is deprecated and is also the only reader that
            // works unchanged from API 26 to 36 — Icon.getResId() is not public
            // across that whole range.
            @Suppress("DEPRECATION")
            assertEquals("the shade icon is not this app's, in $mode", expected, posted!!.icon)

            @Suppress("DEPRECATION")
            assertEquals(
                "the LOCK SCREEN icon is not this app's, in $mode — the public " +
                    "version is a separate builder and is the one that matters most",
                expected,
                posted.publicVersion?.icon,
            )
        }
    }

    /**
     * The mark is a silhouette that actually has a silhouette.
     *
     * Android throws the icon's colour away and tints its **alpha**, so two
     * failures look identical in source and completely different in the status
     * bar: a vector that renders nothing (invisible icon), and one that renders
     * a filled shape (a blob). Neither is caught by asserting which resource id
     * was used.
     *
     * Rendered at the size it is actually drawn and measured. A ring with one
     * dot covers roughly a fifth of its box; the bounds are wide enough not to
     * be a pixel-comparison test, and narrow enough to fail on both mistakes.
     */
    @Test
    fun theStatusBarMarkRendersAsAReadableSilhouette() {
        val size = 24 * context.resources.displayMetrics.density.toInt()
        val drawable = requireNotNull(
            androidx.core.content.ContextCompat.getDrawable(context, R.drawable.ic_notification),
        ) { "ic_notification did not inflate" }

        val bitmap = android.graphics.Bitmap.createBitmap(
            size, size, android.graphics.Bitmap.Config.ARGB_8888,
        )
        drawable.setBounds(0, 0, size, size)
        drawable.draw(android.graphics.Canvas(bitmap))

        var opaque = 0
        for (x in 0 until size) {
            for (y in 0 until size) {
                if (android.graphics.Color.alpha(bitmap.getPixel(x, y)) > 0) opaque++
            }
        }
        val coverage = opaque.toDouble() / (size * size)

        assertTrue(
            "the icon rendered almost nothing (coverage $coverage) — it would be " +
                "invisible in the status bar",
            coverage > 0.08,
        )
        assertTrue(
            "the icon rendered as a near-solid block (coverage $coverage) — a " +
                "small icon is masked to its alpha, so a filled shape becomes a blob",
            coverage < 0.45,
        )
    }

    @Test
    fun directModeIsTheOnlyOneMarkedPublic() {
        val text = NotificationCopy.textFor(
            ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DIRECT, 2,
        )
        notifier.notify(text, NotificationPrivacy.DIRECT, contentIntent = null)

        val notification = postedNotification()
        assertNotNull(notification)
        assertEquals(Notification.VISIBILITY_PUBLIC, notification!!.visibility)
        assertTrue(notification.textOf().contains("period"))
    }

    @Test
    fun noChannelNameSaysAnythingAboutPeriods() {
        // Visible in Settings > Apps > Notifications, on a phone somebody else
        // may be holding.
        NotificationPrivacy.entries.forEach { mode ->
            notifier.ensureChannel(mode)
            val channel = manager.getNotificationChannel(PeriodNotifier.channelId(mode))
            assertNotNull("no channel for $mode", channel)
            val name = channel!!.name.toString().lowercase()
            NotificationCopy.SENSITIVE_WORDS.forEach {
                assertTrue("the $mode channel is called \"$name\"", !name.contains(it))
            }
        }
    }

    @Test
    fun discreetModeNeverPopsOverTheScreen() {
        // A heads-up notification draws its content over whatever is showing,
        // lock or no lock. High importance and Discreet are a contradiction.
        notifier.ensureChannel(NotificationPrivacy.DISCREET)
        val channel = manager.getNotificationChannel(
            PeriodNotifier.channelId(NotificationPrivacy.DISCREET),
        )!!
        assertTrue(
            "importance was ${channel.importance}",
            channel.importance <= NotificationManager.IMPORTANCE_DEFAULT,
        )

        // And the separate channel per mode is what makes this stay true: a
        // single channel keeps its first importance forever, so switching to
        // Direct and back would leave Discreet popping over the screen.
        notifier.ensureChannel(NotificationPrivacy.DIRECT)
        assertEquals(
            NotificationManager.IMPORTANCE_HIGH,
            manager.getNotificationChannel(PeriodNotifier.channelId(NotificationPrivacy.DIRECT))!!.importance,
        )
    }
}
