package dev.privacyllc.period.core.export

import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.util.TimeZone

/**
 * The format is a contract, so it is pinned rather than described.
 *
 * `#35` says the file format "outlives this batch: whatever ships first is what
 * people's archives will be in". The strongest protection available for that is
 * a committed golden file compared byte for byte — a reformat, a reordered key
 * or a changed date rendering all fail here rather than in somebody's archive
 * two years from now.
 *
 * The golden doubles as the documented example, so `SECURITY.md` links at it
 * instead of pasting a second copy that would drift.
 */
class ExportFormatTest {

    private fun golden(): String =
        checkNotNull(javaClass.classLoader.getResourceAsStream("golden-v1.json")) {
            "golden-v1.json is missing — the format has nothing pinning it"
        }.readBytes().decodeToString()

    @Test fun `the rendered file matches the golden byte for byte`() {
        assertEquals(golden(), ExportFixture.render())
    }

    /**
     * The off-by-one that would never show up where it was written.
     *
     * `Converters.kt` stores a `LocalDate` as its epoch day precisely so it
     * "cannot carry a timezone by accident". Any zone-aware formatting in the
     * writer would shift every date for users east or west of the developer,
     * and a cycle tracker off by one day is wrong in the way that matters.
     */
    @Test fun `it renders identically in every time zone`() {
        val original = TimeZone.getDefault()
        try {
            val outputs = listOf("UTC", "Pacific/Kiritimati", "Etc/GMT+12").map { zone ->
                TimeZone.setDefault(TimeZone.getTimeZone(zone))
                zone to ExportFixture.render()
            }
            outputs.forEach { (zone, rendered) ->
                assertEquals("the file changed when the device was in $zone", golden(), rendered)
            }
        } finally {
            TimeZone.setDefault(original)
        }
    }

    /**
     * The whole key set, asserted as a set rather than checked for presence.
     *
     * `assertEquals` and not `contains`, because the claim being defended is
     * "only the user's own data" — and a `contains` check passes happily when a
     * new key appears. Every addition has to be a deliberate edit here.
     */
    @Test fun `the file contains exactly these keys and no others`() {
        val keys = Regex("\"([A-Za-z0-9_]+)\"\\s*:").findAll(ExportFixture.render())
            .map { it.groupValues[1] }
            .toSet()

        val expected = setOf(
            "format", "formatVersion", "aboutThisFile", "exportedOn", "appVersion",
            "periods", "startDate", "endDate", "source", "confirmed",
            "spotting", "date",
            "settings", "notificationPrivacy", "reminderTime", "reminders",
            "periodApproaching", "periodExpectedToday", "didItStart",
            "periodEndCheckIn", "fertileWindow", "ovulation",
            "biometricUnlock", "theme",
        )
        assertEquals(expected, keys)
    }

    /**
     * The exclusions, named individually so a failure says which one came back.
     *
     * Keys, not substrings: `fertileWindow` and `ovulation` are legitimate
     * reminder toggles, and the prose legitimately contains the word
     * "predictions" while saying there are none.
     */
    @Test fun `nothing derived, nothing about the device, and no row ids`() {
        val keys = Regex("\"([A-Za-z0-9_]+)\"\\s*:").findAll(ExportFixture.render())
            .map { it.groupValues[1] }
            .toSet()

        val mustNotAppear = listOf(
            "id", "predictedDate", "prediction", "predictions", "confidence",
            "confidenceScore", "accuracy", "modelVersion", "windowStart", "windowEnd",
            "notYet", "notYetAnswers", "device", "model", "manufacturer", "sdk",
            "locale", "timeZone", "timezone", "adsRemoved", "onboardingCompleted",
            "checkInCount", "createdAt", "updatedAt", "recordedAt", "pin", "verifier",
        )
        val found = mustNotAppear.filter { it in keys }
        assertEquals("a key that must never be exported is in the file", emptyList<String>(), found)
    }

    /**
     * Every enum constant survives the safe-character check.
     *
     * The renderer emits enum `name`s directly and escapes nothing, which is
     * safe exactly while no value can contain a quote or a backslash. Renaming
     * a constant to something exotic would otherwise produce a file that is not
     * JSON — in the user's archive, not in a build.
     *
     * Only `PeriodRecordSource` can be checked here: `NotificationPrivacy` and
     * `AppTheme` live in `core/datastore`, which is deliberately not on this
     * module's classpath. The mapping test in `:app` covers those.
     */
    @Test fun `every period source constant is safe to write unescaped`() {
        PeriodRecordSource.entries.forEach { source ->
            val rendered = ExportDocument.render(
                periods = listOf(ExportFixture.periods.first().copy(source = source)),
                spotting = emptyList(),
                settings = ExportFixture.settings,
                appVersion = ExportFixture.APP_VERSION,
                exportedOn = ExportFixture.EXPORTED_ON,
            )
            assertTrue("$source did not reach the file", rendered.contains("\"${source.name}\""))
        }
    }

    @Test fun `a value outside the safe set is refused rather than written`() {
        val thrown = runCatching {
            ExportDocument.render(
                periods = emptyList(),
                spotting = emptyList(),
                settings = ExportFixture.settings.copy(theme = """SYS"TEM"""),
                appVersion = ExportFixture.APP_VERSION,
                exportedOn = ExportFixture.EXPORTED_ON,
            )
        }.exceptionOrNull()

        assertTrue(
            "a quote in a value must not reach the file",
            thrown is IllegalArgumentException,
        )
        // The message must not carry the offending value: it could be anything,
        // and this exception is exactly what a crash reporter collects.
        assertTrue(
            "the refusal leaked the value it refused: ${thrown?.message}",
            thrown?.message?.contains("SYS") != true,
        )
    }

    /**
     * A user who exports before recording anything gets a real file, not a
     * crash and not an empty one — and every key is still present, per rule 3.
     */
    @Test fun `empty history still produces a valid file`() {
        val rendered = ExportDocument.render(
            periods = emptyList(),
            spotting = emptyList(),
            settings = ExportFixture.settings,
            appVersion = ExportFixture.APP_VERSION,
            exportedOn = LocalDate.of(2026, 1, 1),
        )
        assertTrue(rendered.contains("\"periods\": [\n  ]"))
        assertTrue(rendered.contains("\"spotting\": [\n  ]"))
        assertTrue(rendered.contains("\"format\""))
        assertTrue(rendered.trimEnd().endsWith("}"))
    }
}
