// AGP 9 has built-in Kotlin support, so there is no org.jetbrains.kotlin.android
// plugin here — applying it is now an error rather than a redundancy.
// The Compose compiler plugin is still applied separately.
// See https://developer.android.com/build/migrate-to-built-in-kotlin
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.jvm) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.room) apply false
}

// ===========================================================================
// Module boundaries
// ===========================================================================
//
// The dependency table in docs/architecture/README.md, as a check.
//
// Two boundaries in this project are load-bearing and neither can be held up by
// people remembering them:
//
//   1. `domain:*` must not see the Android SDK. It is why the prediction
//      engine's tests run in a second instead of on an emulator, and one
//      contributor reaching for a convenient Android API would end that with no
//      test failing.
//   2. The advertising subsystem must never reach cycle data. PRODUCT_PLAN.md
//      §34 states it as non-negotiable: no menstrual date, cycle length,
//      fertility status, ovulation estimate, prediction confidence, prediction
//      history or spotting record may reach advertising, ever.
//
// The second is written down before `core:ads` exists on purpose. A guard added
// alongside the code it constrains is a guard that was shaped around whatever
// exception somebody wanted at the time.
//
// Run: ./gradlew checkModuleBoundaries   (also wired into `check`)
// Prove it fails: bash scripts/prove-guard.sh
//
// Exit is non-zero with every violation listed, not just the first — a guard
// that reports one problem per run turns a five-minute fix into five runs.

/** Project dependencies each module is permitted. Anything else fails. */
val allowedProjectDependencies: Map<String, Set<String>> = mapOf(
    ":app" to setOf(
        ":core:designsystem", ":core:data", ":core:datastore", ":core:notifications",
        ":domain:cycle", ":domain:prediction",
    ),
    ":core:designsystem" to emptySet(),
    ":core:database" to setOf(":domain:cycle", ":domain:prediction"),
    ":core:datastore" to emptySet(),
    ":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"),
    ":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"),
    ":domain:cycle" to emptySet(),
    ":domain:prediction" to setOf(":domain:cycle"),
    // Batch 07. Empty, and that is the whole point: the ads module may reach
    // NOTHING in this project. It talks to the UI through an AdProvider
    // interface owned by :app.
    ":core:ads" to emptySet(),
)

/** Modules that must never see the Android SDK, by never applying an Android plugin. */
val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction")

/**
 * Configurations that describe what SHIPS. Test-only dependencies are not a
 * product boundary — a test may reach for a fake or an in-memory database that
 * production must not — so they are deliberately not examined here.
 */
val shippingConfigurations = setOf("implementation", "api", "compileOnly", "runtimeOnly", "ksp")

// ---------------------------------------------------------------------------
// Collected in afterEvaluate, and that is not a detail.
//
// The first version of this read `subprojects.configurations` directly in the
// root build script. The root project is configured BEFORE its subprojects, so
// every configuration was empty, every module had no dependencies, and the task
// printed "7 modules checked, no violations" while checking nothing at all.
//
// It was caught by `scripts/prove-guard.sh` on the first run — a deliberate
// forbidden dependency was added to :domain:prediction and the guard stayed
// green. That is precisely the failure GUARDS.md §1 exists for, and it is why
// no guard here is believed until it has been watched failing.
// ---------------------------------------------------------------------------

val observedProjectDependencies = mutableMapOf<String, Set<String>>()
val observedAndroidPlugins = mutableMapOf<String, List<String>>()
val containerProjects = mutableSetOf<String>()

subprojects {
    afterEvaluate {
        if (!buildFile.exists()) {
            // `include(":core:database")` makes Gradle create an intermediate
            // `:core` project with nothing to build. Containers, not modules —
            // named in the output rather than dropped, because "skipped" and
            // "passed" must not look the same.
            containerProjects += path
            return@afterEvaluate
        }
        observedProjectDependencies[path] = configurations
            .filter { it.name in shippingConfigurations }
            .flatMap { conf -> conf.dependencies.filterIsInstance<ProjectDependency>() }
            .map { it.path }
            .toSet()
        observedAndroidPlugins[path] = plugins.mapNotNull { plugin ->
            plugin::class.qualifiedName?.takeIf { it.contains("com.android.build") }
        }
    }
}

tasks.register("checkModuleBoundaries") {
    group = "verification"
    description = "Every module's project dependencies must match docs/architecture/README.md."

    val allowed = allowedProjectDependencies
    val observed = observedProjectDependencies
    val androidPlugins = observedAndroidPlugins
    val pureJvm = mustStayPureJvm
    val containers = containerProjects

    // Providers, not values: these maps are filled during afterEvaluate, which
    // has not run when this task is being registered. Reading them eagerly here
    // is the same mistake as reading them in the root script.
    inputs.property("allowed", allowed.toString())
    inputs.property("observed", provider { observed.toString() })
    inputs.property("androidPlugins", provider { androidPlugins.toString() })

    doLast {
        val violations = mutableListOf<String>()

        val modules = observed.keys

        // Refuse to report a pass over nothing. An empty map here means the
        // collection above did not run, which is exactly how this guard was
        // green while checking nothing.
        if (modules.isEmpty()) {
            throw GradleException(
                "no modules were examined, so nothing was checked. This is not a pass — " +
                    "see the afterEvaluate note in build.gradle.kts.",
            )
        }

        // A module nobody declared a rule for is not "allowed anything" — it is
        // unmeasured, and reporting it as a pass is how a boundary quietly stops
        // covering half the project.
        (modules - allowed.keys).sorted().forEach {
            violations += "$it has no entry in allowedProjectDependencies, so its dependencies were never checked."
        }

        observed.forEach { (module, deps) ->
            if (module in containers) return@forEach
            val permitted = allowed[module] ?: return@forEach
            (deps - permitted).sorted().forEach { dep ->
                violations += "$module depends on $dep, which the architecture does not permit."
            }
        }

        pureJvm.forEach { module ->
            androidPlugins[module]?.takeIf { it.isNotEmpty() }?.let { plugins ->
                violations += "$module applies an Android plugin (${plugins.first()}). " +
                    "It must stay pure JVM so the prediction engine is testable without an emulator."
            }
        }

        if (violations.isNotEmpty()) {
            logger.error("")
            logger.error("Module boundary violations:")
            violations.forEach { logger.error("  - $it") }
            logger.error("")
            logger.error("These are the rules in docs/architecture/README.md. If the architecture")
            logger.error("changed on purpose, change that table and the map in build.gradle.kts in")
            logger.error("the same commit. If it did not, this dependency is the mistake.")
            throw GradleException("${violations.size} module boundary violation(s).")
        }

        logger.lifecycle(
            "module boundaries: ${modules.size} module(s) checked, " +
                "${pureJvm.size} required to stay pure JVM, no violations.",
        )
        if (containers.isNotEmpty()) {
            logger.lifecycle(
                "  (skipped ${containers.size} container project(s) with no build file: " +
                    "${containers.sorted().joinToString(", ")})",
            )
        }
    }
}

// ===========================================================================
// Permissions
// ===========================================================================
//
// The Play listing shows this list, the Data Safety form has to describe it, and
// a privacy-first period tracker is judged on it before anybody opens the app.
//
// It is also the list most likely to grow without anyone deciding to grow it: a
// dependency added for one feature brings its own <uses-permission>, the merge
// is silent, and it appears in the store listing months later. Adding
// WorkManager to this project added four in one line — WAKE_LOCK,
// ACCESS_NETWORK_STATE, RECEIVE_BOOT_COMPLETED and FOREGROUND_SERVICE — none of
// them typed by anybody.
//
// So the set is declared here and checked. Growing it is allowed; growing it by
// accident is not.

val allowedPermissions: Set<String> = setOf(
    // Asked for when a reminder is switched on, never on first launch (§31).
    "android.permission.POST_NOTIFICATIONS",

    // The four WorkManager brings. None is requested by this project's own code.
    // RECEIVE_BOOT_COMPLETED is the one that earns its place: it is how a
    // reminder survives a restart.
    "android.permission.WAKE_LOCK",
    "android.permission.ACCESS_NETWORK_STATE",
    "android.permission.RECEIVE_BOOT_COMPLETED",
    "android.permission.FOREGROUND_SERVICE",
)

/**
 * Permissions this app must NEVER declare, whatever else changes.
 *
 * Separate from "not in the allowlist" because these deserve their own message.
 * §31 rules out exact alarms specifically: a period reminder does not need
 * alarm-clock precision, and the permission costs Play scrutiny for nothing.
 */
val forbiddenPermissions: Set<String> = setOf(
    "android.permission.SCHEDULE_EXACT_ALARM",
    "android.permission.USE_EXACT_ALARM",
    "android.permission.ACCESS_FINE_LOCATION",
    "android.permission.ACCESS_COARSE_LOCATION",
    "android.permission.READ_CONTACTS",
    "android.permission.READ_CALENDAR",
    "android.permission.CAMERA",
    "android.permission.RECORD_AUDIO",
)

tasks.register("checkPermissions") {
    group = "verification"
    description = "The merged manifest may declare only the permissions listed in build.gradle.kts."

    val allowed = allowedPermissions
    val forbidden = forbiddenPermissions
    val intermediates = layout.projectDirectory.dir("app/build/intermediates").asFile

    // The manifest has to exist and be CURRENT before this reads it.
    //
    // Without this the task ran happily against whatever was left on disk from
    // a previous build. prove-guard.sh caught it: a deliberate
    // SCHEDULE_EXACT_ALARM was added to the manifest and the check stayed green,
    // because it read the merged file from before the edit. The second guard in
    // this project to be confidently green over exactly its own target.
    // Both variants, and release is the one that matters: the Play listing and
    // the Data Safety form describe the shipped manifest, not the debug one.
    dependsOn(":app:processDebugMainManifest", ":app:processReleaseMainManifest")

    doLast {
        // Walked at execution time, not configuration time — a file tree
        // resolved during configuration does not see a manifest written later
        // in the same build.
        // Only the outputs of the tasks above. AGP also leaves a legacy
        // `merged_manifests` (plural) tree that nothing here regenerates, and
        // reading it meant a stale file from an earlier build failing the check
        // — a guard that cries wolf gets switched off.
        val files = intermediates.resolve("merged_manifest").walkTopDown()
            .filter { it.isFile && it.name == "AndroidManifest.xml" }
            .toList()
        if (files.isEmpty()) {
            // Never a silent pass: no manifest means the check did not run.
            throw GradleException(
                "no merged manifest found, so no permission was checked. Build :app first.",
            )
        }

        // Comments are stripped before parsing. This file's own comment names
        // SCHEDULE_EXACT_ALARM to explain why it is absent, and a naive grep
        // reported the explanation as the violation.
        val commentRe = Regex("<!--.*?-->", RegexOption.DOT_MATCHES_ALL)
        val permissionRe = Regex("""<uses-permission[^>]*android:name="([^"]+)"""")

        val found = files.flatMap { file ->
            permissionRe.findAll(commentRe.replace(file.readText(), ""))
                .map { it.groupValues[1] }
        }.toSet().filterNot { it.endsWith("DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") }

        val bad = found.filter { it in forbidden }
        val unexpected = found.filterNot { it in allowed || it in forbidden }

        if (bad.isNotEmpty() || unexpected.isNotEmpty()) {
            logger.error("")
            bad.forEach {
                logger.error("  FORBIDDEN permission in the merged manifest: $it")
            }
            unexpected.forEach {
                logger.error("  Undeclared permission in the merged manifest: $it")
            }
            logger.error("")
            logger.error("A permission nobody typed usually arrives with a dependency. Find which,")
            logger.error("decide whether this app should have it, and either remove it with")
            logger.error("tools:node='remove' or add it to allowedPermissions with a reason.")
            logger.error("Whatever you do, update docs/security/SECURITY.md — the Play listing and")
            logger.error("the Data Safety form both describe this list.")
            throw GradleException("${bad.size + unexpected.size} unapproved permission(s).")
        }

        logger.lifecycle("permissions: ${found.size} declared, all approved.")
    }
}


// ---------------------------------------------------------------------------
// PRODUCT_PLAN.md §45: no health data in logs, and not by good behaviour
// ---------------------------------------------------------------------------
//
// §45 forbids writing cycle dates to logs and requires verbose logging gone from
// release builds. Today the app honours that by simply not logging at all — two
// references to `android.util.Log` exist in the whole tree and neither is a log
// call. That is a property of nobody having typed one yet, not of anything
// stopping them.
//
// One `Log.d("cycle", record.toString())` added while chasing a defect writes
// menstrual dates to logcat, where a bug report, a crash reporter, and on older
// Android versions any app with log access can read them. It is one line, it
// looks harmless in review, and it is exactly what §45 is written about.
//
// ## The two things that make this guard rather than a grep
//
// Both are GUARDS.md §2, and both are live in this repository right now:
//
//   - `PeriodApplication.kt` passes `android.util.Log.WARN` to WorkManager as a
//     CONSTANT. It is not a log call and must not fail the build. So the check
//     matches a method call — `Log.d(` — and never the class name alone.
//   - `ReminderWorker.kt`'s KDoc says "a `Log.d` in a worker is the kind that
//     survives", explaining why there isn't one. A naive grep fails on the
//     comment that exists to prevent the thing. Comments are stripped first.
//
// A guard that punishes the clearest possible explanation gets the explanation
// deleted, which is a worse outcome than no guard.

/** Modules that can see a cycle date. `core/designsystem` cannot, so it is absent. */
val modulesSeeingHealthData: List<String> = listOf(
    "app", "core/data", "core/database", "core/datastore",
    "core/notifications", "domain/cycle", "domain/prediction",
)

/**
 * Call shapes that put a value somewhere a person can read it later.
 *
 * `printStackTrace` is in the list for the same reason as the rest: the stack it
 * prints carries whatever the exception message holds, and the easiest way to
 * write an exception message is to interpolate the record that caused it.
 */
val forbiddenLoggingCalls: List<String> = listOf(
    "Log.v(", "Log.d(", "Log.i(", "Log.w(", "Log.e(", "Log.wtf(", "Log.println(",
    "println(", "print(", "System.out", "System.err", "printStackTrace(",
)

tasks.register("checkNoHealthLogging") {
    group = "verification"
    description = "No logging call may exist in a module that can see a cycle date (PRODUCT_PLAN §45)."

    val roots = modulesSeeingHealthData.map { layout.projectDirectory.dir(it).asFile }
    val forbidden = forbiddenLoggingCalls
    // Resolved at configuration time. Reaching for `layout` inside doLast
    // captures the Project itself, which the configuration cache refuses to
    // serialize — the build fails with a cache problem rather than a guard
    // result, which reads like the guard is broken when it is not.
    val repoRoot = layout.projectDirectory.asFile

    doLast {
        // Comments go before anything is matched. Block comments first, so a
        // KDoc spanning lines cannot leave its middle behind, then line
        // comments. Kotlin has no nested block comments to worry about here.
        fun codeOf(text: String): String =
            text.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "")
                .lines().joinToString("\n") { it.substringBefore("//") }

        var scanned = 0
        val hits = mutableListOf<String>()

        roots.forEach { root ->
            root.walkTopDown()
                .filter { it.isFile && it.extension == "kt" }
                // `/build/` and `/bin/` are outputs, not source. `bin/` is an
                // IDE artefact, gitignored, and it holds stale COPIES of test
                // files — the first run of this guard failed on a println in a
                // copy of a test that the real tree exempts. A guard that fails
                // on untracked build output is a guard somebody switches off.
                .filterNot { it.path.contains("/build/") || it.path.contains("/bin/") }
                // Test sources are exempt: a test that prints is a test being
                // debugged, it never ships, and forbidding it would push people
                // to debug by other means.
                .filterNot { it.path.contains("/src/test/") || it.path.contains("/src/androidTest/") }
                .forEach { file ->
                    scanned++
                    val code = codeOf(file.readText())
                    code.lines().forEachIndexed { i, line ->
                        forbidden.forEach { pattern ->
                            if (line.contains(pattern)) {
                                hits += "${file.relativeTo(repoRoot)}:${i + 1}  $pattern  ${line.trim()}"
                            }
                        }
                    }
                }
        }

        // Never a silent pass. A path typo in modulesSeeingHealthData would
        // otherwise report a clean build having read nothing at all, which is
        // how the module-boundary guard spent its first day green.
        if (scanned == 0) {
            throw GradleException(
                "no Kotlin sources found, so no logging was checked. " +
                    "Check the paths in modulesSeeingHealthData.",
            )
        }

        if (hits.isNotEmpty()) {
            logger.error("")
            logger.error("PRODUCT_PLAN.md §45: health data must never reach a log.")
            logger.error("")
            hits.forEach { logger.error("  $it") }
            logger.error("")
            logger.error("These modules can see a cycle date, so a log call in them can print one.")
            logger.error("If you are debugging, delete the line before committing. If you genuinely")
            logger.error("need diagnostics, emit a non-sensitive event NAME with no values, as §45's")
            logger.error("debug logging rule shows, and add the call shape to an allowlist here with")
            logger.error("the reason.")
            throw GradleException("${hits.size} logging call(s) where health data is visible.")
        }

        logger.lifecycle("health logging: $scanned Kotlin file(s) checked, no logging calls.")
    }
}

// Wired into `check` so it runs with the tests rather than only when remembered.
subprojects {
    tasks.matching { it.name == "check" }.configureEach {
        dependsOn(rootProject.tasks.named("checkModuleBoundaries"))
        if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions"))
        dependsOn(rootProject.tasks.named("checkNoHealthLogging"))
    }
}
