package com.correx.core.kernel.orchestration

// Split out of ContextFeedback.kt / DefaultSessionOrchestratorRecovery.kt (Vikunja #309) purely to
// stay under detekt's per-file function-count threshold — this is the shared data source (and its
// one consumer that isn't a ContextEntry builder) for the two consumers described below.

import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.retry.FailureFingerprint
import com.correx.core.transitions.graph.WorkflowGraph

/**
 * Per-file repair outcome for a stage's own writes this stage-entry (Vikunja #309, shared data
 * source for two consumers: the retry-feedback ledger in ContextFeedback.kt, and the recovery
 * same-fingerprint guard below). Correlates each [FileWrittenEvent] with the next
 * [LspDiagnosticsCompletedEvent] recorded for that path (invariant #9 — event-derived, no
 * re-observation) to tell "rewriting this file is converging" from "rewriting this file has changed
 * nothing" — the closed/open "tab-keeping" a bare file list can't express.
 */
internal data class FileRepairOutcome(
    val path: String,
    val writeCount: Int,
    /**
     * True when the diagnostic run following the LAST write for this path reported no errors. False
     * when it reported errors OR when no run has happened since that write — see [unchecked], which
     * separates the two. "Not yet checked" must never read as "clean": telling the model to leave a
     * file alone on the strength of a run that never happened is the exact mis-signal this ledger exists
     * to prevent.
     */
    val resolved: Boolean,
    /** True when NO diagnostic run has been recorded since the last write for this path. */
    val unchecked: Boolean,
    /** Diagnostic codes present after EVERY write (only meaningful when [resolved] is false). */
    val persistentCodes: Set<String>,
    /** 1-based write index at which the diagnostic first went clean (only set when [resolved]). */
    val clearedAtWrite: Int?,
)

internal fun fileRepairOutcomes(events: List<StoredEvent>, stageId: StageId): List<FileRepairOutcome> {
    val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
        .associate { it.invocationId to it.stageId }
    val writes = events
        .filter { ev ->
            val fw = ev.payload as? FileWrittenEvent
            fw != null && fw.postImageHash != null && invToStage[fw.invocationId] == stageId
        }
        .sortedBy { it.sequence }
    if (writes.isEmpty()) return emptyList()
    val diagRuns = events
        .filter { (it.payload as? LspDiagnosticsCompletedEvent)?.stageId == stageId }
        .sortedBy { it.sequence }
    val paths = writes.map { (it.payload as FileWrittenEvent).path }.distinct()
    return paths.map { path ->
        val pathWrites = writes.filter { (it.payload as FileWrittenEvent).path == path }
        // null = no diagnostic run recorded after that write, which is NOT the same as a clean run.
        val codesPerWrite: List<Set<String>?> = pathWrites.map { w ->
            diagRuns.firstOrNull { it.sequence > w.sequence }
                ?.let { (it.payload as LspDiagnosticsCompletedEvent).diagnostics }
                ?.filter { d -> d.path == path && d.severity.equals("error", ignoreCase = true) && !d.isLint }
                ?.mapNotNull { it.code }
                ?.toSet()
        }
        val checked = codesPerWrite.filterNotNull()
        val unchecked = codesPerWrite.last() == null
        val resolved = !unchecked && codesPerWrite.last()!!.isEmpty()
        FileRepairOutcome(
            path = path,
            writeCount = pathWrites.size,
            resolved = resolved,
            unchecked = unchecked,
            persistentCodes = if (resolved || checked.isEmpty()) {
                emptySet()
            } else {
                checked.reduce { a, b -> a intersect b }
            },
            clearedAtWrite = if (resolved) codesPerWrite.indexOfFirst { it?.isEmpty() == true } + 1 else null,
        )
    }
}

internal fun describeFileRepairOutcome(o: FileRepairOutcome): String {
    val header = "${o.path} — written ${o.writeCount}x."
    return when {
        o.unchecked -> "$header not re-checked since the last write — outcome unknown."
        o.resolved && o.writeCount > 1 -> "$header cleared after write ${o.clearedAtWrite}. done, leave it."
        o.resolved -> "$header done, leave it."
        o.persistentCodes.isNotEmpty() -> "$header ${o.persistentCodes.joinToString(", ")}: present before " +
            "AND after every write. Re-writing has not changed the result. Change the fix or report unresolvable."
        else -> "$header diagnostic still failing after the last write."
    }
}

/** True when [stageId] is itself declared as the graph's recovery/arbiter stage. */
internal fun DefaultSessionOrchestrator.isRecoveryStage(graph: WorkflowGraph, stageId: StageId): Boolean =
    graph.stages[stageId]?.metadata?.get("role") == "recovery"

/**
 * Same-fingerprint loop-breaker INSIDE the recovery stage itself (Vikunja #309). Recovery runs as a
 * single continuous ReAct loop with a generous round ceiling (see maxToolRounds doc in
 * SessionOrchestrator.kt) — so [repeatedToolFailureLoop]'s cumulative TOOL-failure count never fires
 * when every individual file_edit call itself succeeds; only the downstream diagnostic gate keeps
 * failing on the same file. Detect that instead, via [fileRepairOutcomes]: a path rewritten [limit]
 * times whose diagnostic never cleared is provably stuck, independent of the per-gate progress-aware
 * fingerprint (which the whole-reason-string comparison can be fooled by — unrelated diagnostics
 * elsewhere in the same run make the overall failure text differ each round even though this one
 * file's defect never moved).
 */
internal fun DefaultSessionOrchestrator.recoveryFileLoopBreak(
    sessionId: SessionId,
    stageId: StageId,
    limit: Int,
): String? {
    val stuck = fileRepairOutcomes(repositories.eventStore.read(sessionId), stageId)
        // `unchecked` is excluded deliberately: killing a run terminally demands recorded proof the
        // rewrites aren't working, not the absence of proof that they are.
        .firstOrNull { !it.resolved && !it.unchecked && it.writeCount >= limit }
        ?: return null
    val codes = stuck.persistentCodes.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "its diagnostic"
    return "recovery stage ${stageId.value} rewrote '${stuck.path}' ${stuck.writeCount}x without " +
        "clearing $codes — the same fix has not changed the result. A materially different fix is " +
        "required; escalating instead of continuing to loop."
}

/**
 * Terminal escalation for [recoveryFileLoopBreak]. Recovery is the last-resort stage — there is no
 * further tier to route to — but the run still opens a [FailureTicketOpenedEvent] (the same
 * machinery every other escalation uses, so the stuck file is durably recorded and visible to the
 * operator/dashboards) before failing the workflow terminally, rather than looping again.
 */
internal suspend fun DefaultSessionOrchestrator.escalateRecoveryLoop(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    gate: String,
    reason: String,
): WorkflowResult {
    emit(
        ctx.sessionId,
        FailureTicketOpenedEvent(
            sessionId = ctx.sessionId,
            stageId = stageId,
            gate = RECOVERY_LOOP_BREAK_GATE,
            category = ticketCategory(gate),
            requiredCapability = "file_write",
            routeTo = stageId,
            evidence = reason,
            routeAttempt = 1,
            fingerprint = FailureFingerprint.of(reason),
            escalated = true,
        ),
    )
    return failWorkflow(ctx.sessionId, stageId, reason, retryExhausted = true)
}
