package com.correx.infrastructure.workflow

import com.correx.core.events.events.PlanGroundingVerdict
import com.correx.core.transitions.graph.BuildExpectation
import com.correx.core.transitions.graph.WorkflowGraph
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.PathMatcher

/** Grounding verdict plus the compact findings handed back to the architect on a non-pass. */
data class PlanGroundingResult(
    val verdict: PlanGroundingVerdict,
    val findings: List<String>,
)

/**
 * Deterministic plan grounding (design 2026-07-15 seam 1). Pure over the compiled [WorkflowGraph]
 * plus recorded workspace facts (repo-map paths + project-profile command aliases) — zero inference,
 * zero I/O, so it is replay-safe by construction and complements the structural [PlanLinter].
 *
 * v1 grounds **build prerequisites** (spec row 4): a stage that will run a real build command
 * ([BuildExpectation] resolves to a profile alias that IS configured) against a build manifest that
 * neither exists in the workspace ([repoMapPaths]) nor is produced by any plan stage's declared
 * writes is doomed — the gate would fail forever with nothing to create the prerequisite. That is a
 * `return_to_architect` finding, caught before lock. A stage whose plan scaffolds the manifest
 * earlier, or whose profile has no command (the gate degrades to a safe skip), is grounded.
 *
 * Path/symbol reference grounding (spec rows 2/3) needs a grounding-grade symbol index and is
 * deferred; those assumptions resolve to `unknown`, never `present`, so this phase never fabricates a
 * "present" verdict it cannot prove.
 */
object PlanGrounder {

    fun ground(
        graph: WorkflowGraph,
        repoMapPaths: Set<String>,
        profileCommands: Map<String, String>,
        // False = no repo scan was recorded for this session, so [repoMapPaths] is "unknown", not
        // "empty workspace". Scope grounding proves a path ABSENT, which an unknown set can't do —
        // running it against a missing scan flags every real path as nonexistent (the apps/server
        // "doesn't exist?!" false reject). Skip scope grounding then; the build-manifest check still
        // runs (it degrades safely via manifestProducedByPlan). A recorded scan with 0 entries is a
        // genuine greenfield workspace — scanned=true, so scope grounding still catches a missing scaffold.
        scanned: Boolean = true,
    ): PlanGroundingResult {
        val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
        val manifestProducedByPlan = graph.stages.values
            .flatMap { it.writeManifest + it.expectedFiles }
            .any(::isBuildManifest)

        // A stage that can WRITE files (`file_write`) POPULATES its declared `touches` scope at run time
        // — including files a scaffolder (`npm create vite …`) generates but the plan never declares in
        // `writes`/`expectedFiles` (the architect prompt tells the model to use the real scaffolder, not
        // hand-write package.json). Keyed on file_write, NOT shell: a scaffold stage carries file_write
        // for the files it authors, whereas a read-only shell stage (log inspection, test runs, grep)
        // creates nothing and must not have its scope credited. Without this, grounding rejects every
        // plan that follows the scaffolder guidance (the d038468a "frontend/ doesn't exist / no manifest"
        // false reject). Runtime precondition handling (#167/#170) stays the backstop for a build whose
        // prerequisite genuinely never materialises.
        val writeScopes = graph.stages.values
            .filter { "file_write" in it.effectiveAllowedTools }
            .flatMap { it.touches }
            .mapNotNull(::scopePrefix)
        val prerequisiteAvailable = manifestInWorkspace || manifestProducedByPlan || writeScopes.isNotEmpty()

        // Files/dirs any stage in the plan will create — the "will exist by run time" set for scope grounding.
        val createdPaths = graph.stages.values.flatMap { it.expectedFiles + it.writeManifest }

        val findings = graph.stages.entries.flatMap { (id, cfg) ->
            buildList {
                val alias = cfg.buildExpectation.takeIf { it != BuildExpectation.NONE }?.commandAlias
                val commandConfigured = alias != null && !profileCommands[alias].isNullOrBlank()
                if (commandConfigured && !prerequisiteAvailable) {
                    add(
                        "stage ${id.value}: declares a ${cfg.buildExpectation} build ('$alias') but the workspace " +
                            "has no build manifest and no plan stage creates one — add a bootstrap stage that " +
                            "produces the manifest/config, or verify the prerequisite before this stage runs.",
                    )
                }
                // Scope grounding (design 2026-07-15 seam 1, spec row 2): a stage confined to a `touches`
                // scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
                // under is grounded on a directory that will never exist — the exact session-a35cf1e3
                // "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
                if (scanned) {
                    cfg.touches
                        .filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths, writeScopes) }
                        .forEach { glob ->
                            add(
                                "stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
                                    "and no plan stage creates a file there — scaffold that path first or fix the scope.",
                            )
                        }
                }
            }
        }

        return PlanGroundingResult(
            verdict = if (findings.isEmpty()) PlanGroundingVerdict.PASS else PlanGroundingVerdict.RETURN_TO_ARCHITECT,
            findings = findings,
        )
    }

    /**
     * True if the [glob] scope will be populated by run time: an existing repo-map path or a
     * plan-created path falls under it, OR it overlaps a scaffold stage's scope ([writeScopes] —
     * either direction, since a scaffolder under `frontend/src` also makes a `frontend/` scope non-empty
     * and a scaffolder of `frontend/` covers `frontend/src`).
     */
    private fun scopeSatisfied(
        glob: String,
        repoMapPaths: Set<String>,
        createdPaths: List<String>,
        writeScopes: List<String>,
    ): Boolean {
        scopePrefix(glob)?.let { p ->
            if (writeScopes.any { it == p || it.startsWith("$p/") || p.startsWith("$it/") }) return true
        }
        val matcher: PathMatcher = runCatching {
            FileSystems.getDefault().getPathMatcher("glob:${glob.trimStart('/')}")
        }.getOrElse { return true } // ponytail: an unparseable glob is not a grounding failure — don't block on it.
        fun matches(p: String) = runCatching { matcher.matches(Path.of(p.trimStart('/'))) }.getOrDefault(false)
        return repoMapPaths.any(::matches) || createdPaths.any(::matches)
    }

    // A scope glob reduced to its literal directory prefix (a "frontend" wildcard glob → "frontend"),
    // or null when the glob has no literal prefix.
    private fun scopePrefix(glob: String): String? =
        glob.trimStart('/').substringBefore('*').trimEnd('/').ifEmpty { null }

    private fun isBuildManifest(path: String): Boolean =
        path.substringAfterLast('/').lowercase() in BUILD_MANIFEST_FILENAMES

    // Mirrors the runtime BUILD_PREREQUISITE_FILENAMES in SessionOrchestratorPreconditions — the same
    // files that trip a repeated-reference precondition failure at run time are the prerequisites
    // grounded here at plan time (design #167/#170 complement).
    private val BUILD_MANIFEST_FILENAMES = setOf(
        "package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
    )
}
