package com.correx.infrastructure.workflow

import com.correx.core.events.events.PlanLintFinding
import com.correx.core.events.types.StageId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph

/**
 * Result of linting a compiled ExecutionPlan. [passed] is false when any hard failure is present —
 * such a candidate is unfit and must not be locked/run. [score] is the weighted soft-finding penalty
 * (lower is better) used by the future tournament ranking (plan-pipeline-spec §9).
 */
data class PlanLintResult(
    val hardFailures: List<PlanLintFinding>,
    val softFindings: List<PlanLintFinding>,
) {
    val passed: Boolean get() = hardFailures.isEmpty()
    val score: Int get() = softFindings.size
}

/**
 * Deterministic, pure-Kotlin lint over a compiled plan graph (plan-pipeline-spec §5). Zero inference,
 * zero I/O — a function of the graph and a fixed seed set, so it is replay-safe by construction. It complements
 * [ExecutionPlanCompiler] (which already throws on unreachable-from-start / unknown-kind / bad-edge):
 * the lint adds the checks the compiler does NOT make.
 *
 * v1 is graph-structural only. File-path grounding and symbol resolution are deferred (symbols need a
 * real index; file grounding overlaps the analyst-brief grounding step).
 */
object PlanLinter {

    private const val STAGE_CEILING = 12
    private const val FAN_OUT_THRESHOLD = 4

    /**
     * Artifacts that pre-exist in the session before the execution plan runs — produced by the
     * planning phase, not by any plan stage. The architect prompt explicitly allows `needs` to
     * reference these (notably `analysis`), so the linter must treat them as available producers.
     */
    private val seedArtifacts = setOf("analysis", "dod")

    fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
        PlanLintResult(
            // HARD = deterministic graph facts, provably broken regardless of wording. SOFT = heuristics
            // (incl. H3's prose word-match, which cannot tell a forgotten dep from a descriptive mention)
            // — they score the plan but must NOT block it, or a weak architect burns retries it can't
            // reword its way out of.
            hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph),
            softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph) +
                unreferencedPromptArtifacts(graph, seeds),
        )

    /** H1: a stage `needs` an artifact that neither a plan stage `produces` nor a seed provides. */
    private fun unproducedNeeds(graph: WorkflowGraph, seeds: Set<String>): List<PlanLintFinding> {
        val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet() + seeds
        return graph.stages.entries.flatMap { (id, stage) ->
            stage.needs.map { it.value }.filterNot { it in produced }.map { need ->
                PlanLintFinding(
                    code = "unproduced_need",
                    stageId = id.value,
                    detail = "needs artifact '$need', which no stage produces",
                )
            }
        }
    }

    /**
     * H2: a stage with no path to the terminal (`done` / any edge target not in `stages`). Subsumes
     * inescapable cycles and dead-end sinks — only "no exit" fails, so legitimate verdict-gated review
     * loops (which DO have an exit edge) pass.
     */
    private fun trapStates(graph: WorkflowGraph): List<PlanLintFinding> {
        val outTargets: Map<StageId, List<StageId>> =
            graph.transitions.groupBy({ it.from }, { it.to })
        val escapable = mutableSetOf<StageId>()
        var changed = true
        while (changed) {
            changed = false
            for (id in graph.stages.keys) {
                if (id in escapable) continue
                val targets = outTargets[id].orEmpty()
                if (targets.any { it !in graph.stages || it in escapable }) {
                    escapable.add(id)
                    changed = true
                }
            }
        }
        return (graph.stages.keys - escapable).map { id ->
            PlanLintFinding(
                code = "trap_state",
                stageId = id.value,
                detail = "no path to the terminal — an inescapable loop or dead-end stage",
            )
        }
    }

    /**
     * H3 (SOFT): a stage's inline prompt word-matches a plan-produced artifact ID the stage does not
     * declare in `needs` — possibly a dangling reference the model can't resolve at runtime. This is a
     * prose heuristic, not a graph fact: it cannot distinguish a forgotten dependency from a
     * descriptive mention ("unlike the `report` stage"), so it only penalises the plan's score and
     * never blocks it. Seeds are excluded (see below); a genuine missing consumption is still caught
     * structurally by H1 when the stage actually declares the `needs`.
     */
    private fun unreferencedPromptArtifacts(graph: WorkflowGraph, seeds: Set<String>): List<PlanLintFinding> {
        // Only plan-PRODUCED artifacts are checked — a prompt that references a distinctly-named one
        // (e.g. `server_api_analysis`) but omits it from `needs` is a real dangling dependency. Seed
        // IDs (`analysis`, `dod`) are EXCLUDED: they double as ordinary words, so a prose mention
        // ("refine the analysis", "meets the DoD") is not a dependency signal — flagging it just churns
        // architect retries on a false positive the model cannot word around. (A stage that genuinely
        // consumes a seed still declares it in `needs`; H1 keeps that path honest.)
        val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet() - seeds
        return graph.stages.entries.flatMap { (id, stage) ->
            val prompt = briefOf(stage)
            val needIds = stage.needs.map { it.value }.toSet()
            val ownProduces = stage.produces.map { it.name.value }.toSet()
            produced.filter { prodId ->
                prompt.isNotBlank() &&
                    prodId.length >= 2 && // skip single-char IDs (false-positive risk with substring matching)
                    Regex("\\b${Regex.escape(prodId)}\\b").containsMatchIn(prompt) &&
                    prodId !in needIds &&
                    prodId !in ownProduces
            }.map { prodId ->
                PlanLintFinding(
                    code = "unreferenced_prompt_artifact",
                    stageId = id.value,
                    detail = "prompt mentions '$prodId' but it is not declared in needs",
                )
            }
        }
    }

    /** S1: more stages than the ceiling. */
    private fun stageCount(graph: WorkflowGraph): List<PlanLintFinding> =
        if (graph.stages.size > STAGE_CEILING) {
            listOf(
                PlanLintFinding(
                    code = "stage_count",
                    stageId = null,
                    detail = "${graph.stages.size} stages exceeds the ceiling of $STAGE_CEILING",
                ),
            )
        } else {
            emptyList()
        }

    /** S2: a stage with more outgoing edges than the fan-out threshold. */
    private fun fanOut(graph: WorkflowGraph): List<PlanLintFinding> {
        val outDegree = graph.transitions.groupingBy { it.from }.eachCount()
        return graph.stages.keys.mapNotNull { id ->
            val degree = outDegree[id] ?: 0
            if (degree > FAN_OUT_THRESHOLD) {
                PlanLintFinding(
                    code = "fan_out",
                    stageId = id.value,
                    detail = "$degree outgoing edges exceeds the threshold of $FAN_OUT_THRESHOLD",
                )
            } else {
                null
            }
        }
    }

    /** S3: a stage with a blank inline brief. */
    private fun emptyBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
        graph.stages.entries.filter { (_, stage) -> briefOf(stage).isBlank() }.map { (id, _) ->
            PlanLintFinding(code = "empty_brief", stageId = id.value, detail = "stage has an empty inline brief")
        }

    /** S4: two or more stages sharing an identical normalized brief. */
    private fun duplicateBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
        graph.stages.entries
            .filterNot { (_, stage) -> briefOf(stage).isBlank() }
            .groupBy({ (_, stage) -> normalize(briefOf(stage)) }, { (id, _) -> id.value })
            .values
            .filter { it.size > 1 }
            .map { ids ->
                PlanLintFinding(
                    code = "duplicate_brief",
                    stageId = null,
                    detail = "stages share an identical brief: ${ids.sorted().joinToString(", ")}",
                )
            }

    private fun briefOf(stage: StageConfig): String = stage.metadata["promptInline"] ?: ""

    private fun normalize(text: String): String = text.trim().lowercase().replace(Regex("\\s+"), " ")
}
