package com.correx.core.kernel.orchestration

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive

/**
 * Pure deterministic diff between an analyst brief and a model's echo restatement.
 *
 * Determinism / invariant #8: reads ONLY the two recorded artifact strings — no I/O, no
 * external calls. Because both inputs are already in the event log (ArtifactCreatedEvent),
 * the result is recomputable on replay without re-emitting any observation event.
 *
 * isMismatch triggers gate failure when requirements are uncovered or files are hallucinated.
 * Hallucinated symbols are recorded for audit but do NOT block in v1 (display-only, mirroring
 * the role-reliability contradiction-check "display-only in v1" stance).
 */
data class BriefEchoDivergence(
    val uncoveredRequirements: List<String>,
    val hallucinatedFiles: List<String>,
    /** Recorded for audit/narration; does NOT contribute to isMismatch in v1. */
    val hallucinatedSymbols: List<String>,
) {
    val isMismatch: Boolean
        get() = uncoveredRequirements.isNotEmpty() || hallucinatedFiles.isNotEmpty()
}

internal object BriefEchoDiff {

    /** Jaccard overlap below this threshold means a requirement is "uncovered" by the echo. */
    private const val COVERAGE_THRESHOLD = 0.34

    /** Minimum token length for comparison — very short tokens add noise. */
    private const val MIN_TOKEN_LENGTH = 2

    private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
    private val nonAlphanumeric = Regex("[^A-Za-z0-9]+")

    /**
     * Compare the analyst brief JSON against the model's echo JSON, returning divergences.
     * On an unparseable echo, every brief requirement is reported as uncovered (total mismatch),
     * so a garbage echo always fails the gate.
     */
    fun compare(briefJson: String, echoJson: String): BriefEchoDivergence {
        val brief = parseJson(briefJson)
        val echo = parseJson(echoJson)
        val briefFullTokens = tokenize(briefJson)
        val briefFiles = BriefReferenceExtractor.fileReferences(briefJson)
        return BriefEchoDivergence(
            uncoveredRequirements = uncoveredRequirements(brief, echo),
            hallucinatedFiles = hallucinatedFiles(echo, briefFullTokens, briefFiles),
            hallucinatedSymbols = hallucinatedSymbols(echo, briefFullTokens),
        )
    }

    /** Each brief requirement whose best Jaccard overlap with any echo criterion is below threshold. */
    private fun uncoveredRequirements(brief: JsonObject?, echo: JsonObject?): List<String> {
        val requirements = brief?.let { stringList(it, "requirements") } ?: emptyList()
        val criteria = echo?.let { stringList(it, "acceptance_criteria") } ?: emptyList()
        return requirements.filter { req ->
            val reqTokens = tokenize(req)
            if (reqTokens.isEmpty()) return@filter false
            val bestOverlap = criteria.maxOfOrNull { jaccard(reqTokens, tokenize(it)) } ?: 0.0
            bestOverlap < COVERAGE_THRESHOLD
        }
    }

    /**
     * Echo files not grounded in the brief. A file is hallucinated when no token of its basename
     * stem (extension dropped — an extension like "kt" matches any source file in the brief and is
     * pure noise) appears in the brief, AND it matches no extracted brief file path.
     */
    private fun hallucinatedFiles(
        echo: JsonObject?,
        briefFullTokens: Set<String>,
        briefFiles: List<String>,
    ): List<String> {
        val echoFiles = echo?.let { stringList(it, "referenced_files") } ?: emptyList()
        return echoFiles.filter { f ->
            val stemTokens = tokenize(f.substringAfterLast('/').substringBeforeLast('.'))
            if (stemTokens.isEmpty()) return@filter true
            stemTokens.none { it in briefFullTokens } &&
                briefFiles.none { it == f || it.endsWith(f) || f.endsWith(it) }
        }
    }

    /** Echo symbols with no token present anywhere in the brief (recorded for audit, non-blocking). */
    private fun hallucinatedSymbols(echo: JsonObject?, briefFullTokens: Set<String>): List<String> {
        val echoSymbols = echo?.let { stringList(it, "referenced_symbols") } ?: emptyList()
        return echoSymbols.filter { sym -> tokenize(sym).none { it in briefFullTokens } }
    }

    /** Lowercase, replace non-alphanumeric with spaces, split, drop tokens shorter than MIN_TOKEN_LENGTH. */
    internal fun tokenize(text: String): Set<String> =
        text.lowercase()
            .replace(nonAlphanumeric, " ")
            .trim()
            .split(" ")
            .filter { it.length >= MIN_TOKEN_LENGTH }
            .toSet()

    private fun jaccard(a: Set<String>, b: Set<String>): Double {
        if (a.isEmpty() && b.isEmpty()) return 1.0
        val intersection = (a intersect b).size
        val union = (a union b).size
        return if (union == 0) 0.0 else intersection.toDouble() / union
    }

    private fun parseJson(json: String): JsonObject? {
        val stripped = stripFence(json)
        return runCatching { lenientJson.parseToJsonElement(stripped) as? JsonObject }.getOrNull()
    }

    private fun stringList(obj: JsonObject, key: String): List<String> =
        runCatching {
            (obj[key] as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
        }.getOrElse { emptyList() }

    private fun stripFence(text: String): String {
        val trimmed = text.trim()
        if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
        val withoutClose = trimmed.removeSuffix("```").trimEnd()
        val firstNewline = withoutClose.indexOf('\n')
        return if (firstNewline < 0) text else withoutClose.substring(firstNewline + 1)
    }
}
