package dev.privacyllc.period.core.database

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File

/**
 * What the committed schema says, checked against what the code produces.
 *
 * ## Read this before trusting it: it does NOT catch schema drift
 *
 * That is the obvious thing to assume it does, and it was written believing it.
 * It was then proved otherwise, which is why the disclaimer is here rather than
 * a comment saying it works.
 *
 * **Room regenerates the schema export during compilation.** By the time any
 * test runs, `schemas/<db>/1.json` has already been overwritten to describe
 * whatever the entities now say. Both sides of every comparison below therefore
 * agree by construction. Adding a column to `PeriodRecordEntity` without
 * touching [PeriodDatabase.VERSION] leaves this whole class green.
 *
 * The guard that does catch it is `scripts/schema-guard.sh`, which asks **git**
 * — the one party Room cannot overwrite — whether an already-committed schema
 * file has changed. It runs in `.githooks/pre-commit`. Its header records the
 * proof.
 *
 * ## So what is this for
 *
 * Three things the script cannot see, all of them about internal consistency at
 * a single point in time:
 *
 *  - a schema file exists for the declared version, and declares that version;
 *  - every table and index Room generates is one the schema knows about, with
 *    identical SQL — which catches a converter or an index annotation that
 *    silently stopped applying;
 *  - the database grew no table nobody declared.
 *
 * Keep both. Neither is sufficient, and the pair is only trustworthy because
 * each one's limits are written down.
 *
 * ## Why not MigrationTestHelper
 *
 * Every constructor of Room's own helper requires an `android.app.Instrumentation`
 * and reads schemas from the test APK's assets. Wiring that up needs an asset
 * source directory on the `test` source set, and AGP 9's library source-set DSL
 * throws `DefaultAndroidLibrarySourceSet_Decorated cannot be cast to
 * AndroidLibrarySourceSet` when you add one — a defect in the build tool, not
 * in the wiring. The instrumented alternative needs an emulator, which is the
 * kind of test that stops being run.
 *
 * When version 2 arrives, revisit: if the AGP defect is fixed by then, a real
 * migration test is worth more than these.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SchemaTest {

    private lateinit var db: PeriodDatabase

    @Before fun open() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            PeriodDatabase::class.java,
        ).allowMainThreadQueries().build()
        // Room is lazy; touch it so the schema is actually created.
        db.openHelper.writableDatabase
    }

    @After fun close() = db.close()

    private fun schemaDir(): File {
        var dir: File? = File("").absoluteFile
        while (dir != null) {
            val candidate = File(dir, "schemas/${PeriodDatabase::class.java.name}")
            if (candidate.isDirectory) return candidate
            dir = dir.parentFile
        }
        error("no schemas/ directory found from ${File("").absolutePath} upwards")
    }

    private fun committedSchema(version: Int): JSONObject {
        val f = File(schemaDir(), "$version.json")
        assertTrue(
            "schemas/${PeriodDatabase::class.java.name}/$version.json is missing — the " +
                "database version was bumped without exporting the schema, so nothing " +
                "can ever validate a migration into it",
            f.isFile,
        )
        return JSONObject(f.readText()).getJSONObject("database")
    }

    /**
     * Normalise the two cosmetic differences Room and SQLite genuinely
     * disagree on, and nothing else.
     *
     * Room emits `CREATE TABLE IF NOT EXISTS`; SQLite stores what it was given
     * minus that clause. Backticks are optional quoting. Everything past this —
     * every column, type, nullability, default and constraint — is compared
     * literally, which is the point.
     */
    private fun normalise(sql: String) = sql
        .replace("`", "")
        .replace("IF NOT EXISTS ", "")
        .replace(Regex("\\s+"), " ")
        .replace(" ,", ",")
        .trim()

    private fun liveSql(): Map<String, String> {
        val out = mutableMapOf<String, String>()
        db.openHelper.readableDatabase.query(
            "SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'",
        ).use { c ->
            while (c.moveToNext()) out[c.getString(0)] = c.getString(1)
        }
        return out
    }

    @Test
    fun `a schema file is committed for the current database version`() {
        val schema = committedSchema(PeriodDatabase.VERSION)
        assertEquals(
            "the committed schema declares a different version than PeriodDatabase.VERSION",
            PeriodDatabase.VERSION,
            schema.getInt("version"),
        )
    }

    @Test
    fun `every table in the committed schema exists with exactly the same SQL`() {
        val schema = committedSchema(PeriodDatabase.VERSION)
        val live = liveSql()
        val entities = schema.getJSONArray("entities")

        assertTrue("the committed schema declares no tables at all", entities.length() > 0)

        for (i in 0 until entities.length()) {
            val e = entities.getJSONObject(i)
            val table = e.getString("tableName")
            val expected = e.getString("createSql").replace("\${TABLE_NAME}", table)

            assertTrue("table '$table' is in the committed schema but not in the database", live.containsKey(table))
            assertEquals(
                "table '$table' has drifted from the committed schema — change the entity " +
                    "and the schema was not re-exported, or the version was not bumped",
                normalise(expected),
                normalise(live.getValue(table)),
            )
        }
    }

    @Test
    fun `every index in the committed schema exists with exactly the same SQL`() {
        val schema = committedSchema(PeriodDatabase.VERSION)
        val live = liveSql()
        val entities = schema.getJSONArray("entities")
        var checked = 0

        for (i in 0 until entities.length()) {
            val e = entities.getJSONObject(i)
            val table = e.getString("tableName")
            val indices = e.optJSONArray("indices") ?: continue
            for (j in 0 until indices.length()) {
                val idx = indices.getJSONObject(j)
                val name = idx.getString("name")
                val expected = idx.getString("createSql").replace("\${TABLE_NAME}", table)

                assertTrue("index '$name' is in the committed schema but not in the database", live.containsKey(name))
                assertEquals(
                    "index '$name' has drifted from the committed schema",
                    normalise(expected),
                    normalise(live.getValue(name)),
                )
                checked++
            }
        }

        // Four unique indices guard against duplicate dates. Zero would mean
        // this test passed by checking nothing — exit code 2 in test form.
        assertTrue("no indices were checked, so this test verified nothing", checked >= 4)
    }

    @Test
    fun `the database has no table the committed schema does not know about`() {
        val schema = committedSchema(PeriodDatabase.VERSION)
        val declared = buildSet {
            val entities = schema.getJSONArray("entities")
            for (i in 0 until entities.length()) add(entities.getJSONObject(i).getString("tableName"))
            val views = schema.optJSONArray("views")
            if (views != null) for (i in 0 until views.length()) add(views.getJSONObject(i).getString("viewName"))
        }

        // room_master_table is Room's own identity-hash row; android_metadata is
        // created by the platform's SQLiteDatabase and belongs to neither side.
        val internal = setOf("room_master_table", "android_metadata", "sqlite_sequence")
        val liveTables = liveSql().keys.filter { !it.startsWith("index_") && it !in internal }
        val undeclared = liveTables.filterNot { it in declared }

        assertTrue(
            "the database has tables the committed schema does not declare: $undeclared",
            undeclared.isEmpty(),
        )
    }
}
