package com.correx.core.kernel.orchestration

// Context-entry builders kept top-level (not orchestrator members) for unit-testability
// and to stay under detekt's function-count threshold (same pattern as isBackEdge).

import com.correx.core.artifacts.kind.ArtifactKind
import com.correx.core.events.events.RepoKnowledgeHit
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.PlanGroundingEvaluatedEvent
import com.correx.core.events.events.PlanGroundingVerdict
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.WorkflowGraph
import java.util.UUID

// A cold retry that received only the failure text re-discovered its own broken file from scratch —
// the 7dfd75d0 case (three consecutive build-gate retries on the identical `queries.ts(39,3): '}'
// expected`). The fix is a repair bundle: alongside the failure, name the authoritative current CAS
// images of the files this stage has already written so the model patches the recorded image instead
// of rebuilding. Every fact is event-derived (FileWrittenEvent.postImageHash — invariant #9), so no
// CAS read and no re-observation; the hash is authoritative, the path list is disposable navigation.
fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
    val latest = events
        .mapNotNull { it.payload as? RetryAttemptedEvent }
        .lastOrNull { it.stageId == stageId } ?: return null
    val outcomes = fileRepairOutcomes(events, stageId)
    val content = buildString {
        appendLine("## Retry repair state")
        appendLine(
            "Attempt ${latest.attemptNumber} of ${latest.maxAttempts} for stage " +
                "'${stageId.value}', gate '${latest.gate}'. The previous attempt failed:",
        )
        appendLine(latest.failureReason)
        if (outcomes.isNotEmpty()) {
            appendLine()
            appendLine(
                "Files you have already written this stage (authoritative current state — patch " +
                    "these, do NOT re-read to rediscover them). Each is annotated with whether " +
                    "re-writing it actually moved the diagnostic — a file marked unresolved needs a " +
                    "DIFFERENT fix, not another identical rewrite:",
            )
            outcomes.forEach { o -> appendLine("- ${describeFileRepairOutcome(o)}") }
        }
        append(
            "Repair the recorded image and the named failure above first. Do not re-discover " +
                "unrelated files before it builds.",
        )
    }
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L1,
        content = content,
        sourceType = "retryFeedback",
        sourceId = stageId.value,
        tokenEstimate = content.length / 4,
        // #293: USER (not SYSTEM) so PromptRenderer routes it to the trailing repair-mandate slot —
        // the final message after the tool transcript — rather than folding it into leading system.
        role = EntryRole.USER,
    )
}

// FileRepairOutcome / fileRepairOutcomes / describeFileRepairOutcome moved to
// RecoveryFileLoopBreak.kt (Vikunja #309) — shared with the recovery-stage guard there, and split out
// to keep both this file and DefaultSessionOrchestratorRecovery.kt under detekt's function-count cap.

/**
 * Feeds the deterministic plan-grounding findings back into the architect stage when the freestyle
 * driver returned its plan for another attempt (grounding verdict != PASS). The findings are already
 * recorded on [PlanGroundingEvaluatedEvent] (invariant #9), so this reads them rather than re-deriving.
 * Gated to the plan-producing stage — "architect" in freestyle_planning, the same stage id the driver
 * stamps on rejection. Last event wins: a re-run that grounds PASS clears the feedback automatically.
 */
fun buildGroundingFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
    if (stageId.value != "architect") return null
    val latest = events.mapNotNull { it.payload as? PlanGroundingEvaluatedEvent }.lastOrNull() ?: return null
    if (latest.verdict == PlanGroundingVerdict.PASS) return null
    val content = "## Plan grounding feedback\n" +
        "Your previous execution_plan was returned — it does not hold against the workspace facts:\n" +
        latest.findings.joinToString("\n") { "- $it" } + "\n" +
        "Emit a corrected plan that resolves every point above. Typical fixes: declare the manifest a " +
        "build stage needs (have an earlier stage create it via `writes`/`expectedFiles`), narrow a " +
        "stage's `touches` to paths that exist or that an earlier stage creates, or drop a build stage " +
        "that has nothing to build. Do not repeat the identical plan."
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L1,
        content = content,
        sourceType = "groundingFeedback",
        sourceId = stageId.value,
        tokenEstimate = content.length / 4,
        // #312: a gate verdict is run-state, not standing instruction — USER, trailing slot.
        role = EntryRole.USER,
    )
}

/**
 * Feeds the open failure ticket into the recovery stage's context. The recovery stage was entered
 * by kernel routing (not a normal edge) precisely because an upstream write-less stage failed a gate
 * it lacked the capability to fix; without the ticket the recovery stage has no idea what to repair.
 * Surfaces the failing stage, the gate, and the captured gate output (evidence, recorded at
 * ticket-open per invariant #9) so recovery can act on recorded fact, not a re-observation.
 */
fun buildRecoveryTicketEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
    val ticket = events
        .mapNotNull { it.payload as? FailureTicketOpenedEvent }
        .lastOrNull { it.routeTo == stageId } ?: return null
    val intent = events.mapNotNull { it.payload as? InitialIntentEvent }.lastOrNull()?.intent
    val content = if (ticket.gate == STAGE_LOOP_BREAK_GATE) {
        // A stuck-loop route is NOT a cross-file contract dispute — the gate output names no files, it
        // is the SAME action (often a malformed tool call) repeated until the loop-break tripped. The
        // arbitration prompt (read/reconcile the named files) sends the model hunting for files that
        // don't exist. Tell it plainly: the last action is futile, take a materially different one.
        "## Stuck-loop ticket\n" +
            "Stage '${ticket.stageId.value}' repeated the SAME failing action until it tripped the " +
            "'${ticket.gate}' gate. Retrying that action again is futile — it will fail identically. The " +
            "exact failure is below; it is a single stuck step, NOT a dispute between files, so do not go " +
            "looking for files to reconcile. If it is a malformed tool call, fix the call's shape and " +
            "continue the task; otherwise take a materially different route to the same goal. Serve the " +
            "intent" + (intent?.let { " below" } ?: "") + ", then control returns to verification." +
            (intent?.let { "\n### Intent (authoritative)\n$it" } ?: "") +
            "\n### Gate output (the failing action)\n${ticket.evidence}"
    } else if (ticket.escalated) {
        // Tier 2: the owner loop couldn't settle it — a cross-file contract dispute. The arbiter holds
        // the intent and reconciles ALL sides in one pass.
        "## Contract arbitration ticket\n" +
            "Stage '${ticket.stageId.value}' keeps failing the '${ticket.gate}' gate even after the " +
            "file owners repaired their own layers — so this is NOT a bug in one file. The files named " +
            "in the gate output below disagree on a shared contract (a prop/type/API shape): each side " +
            "is internally consistent but they do not agree with each other, so fixing one re-breaks " +
            "the other. You are the arbiter. Read EVERY file named below, decide the single authoritative " +
            "shape" + (intent?.let { " that serves the intent" } ?: "") + ", then edit ALL of them in " +
            "ONE pass so they agree. Do not rewrite files wholesale — change only what the contract " +
            "requires. Then control returns to verification." +
            (intent?.let { "\n### Intent (authoritative)\n$it" } ?: "") +
            "\n### Gate output\n${ticket.evidence}"
    } else {
        "## Recovery ticket\n" +
            "Stage '${ticket.stageId.value}' failed the '${ticket.gate}' gate (${ticket.category}) and " +
            "lacks the '${ticket.requiredCapability}' capability to fix it, so the failure was routed to " +
            "you — you own the file(s) the gate names below. Make the SMALLEST change that clears the " +
            "specific error: read the current file, then patch only the offending lines (file_edit). Do " +
            "NOT rewrite the file from scratch — a full regeneration discards your prior working code and " +
            "usually reintroduces the same defect. Then control returns to verification.\n" +
            "### Gate output\n${ticket.evidence}"
    }
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L1,
        content = content,
        sourceType = "recoveryTicket",
        sourceId = stageId.value,
        tokenEstimate = content.length / 4,
        // #312: the recovery stage exists ONLY because of this ticket, yet as SYSTEM it folded in
        // above the whole transcript. It is the same shape as retryFeedback — USER, trailing slot,
        // and highest precedence there.
        role = EntryRole.USER,
    )
}

/**
 * The remaining-delta checklist (stage-termination design 2026-07-11 §1a). A forward-looking,
 * shrinking list of the stage's *unmet* file-contract assertions — "what is left to make true",
 * not "what I have done". Computed by the kernel as contract-minus-current-filesystem-reality and
 * refreshed within the tool loop each time a write lands on a contract file, so the model watches
 * items disappear as it satisfies them; an empty list is the completion signal the stage otherwise
 * lacks. Reading files never changes it, which is what starves the re-read spiral.
 *
 * Pinned as a neverDrop entry by [com.correx.core.context.builder.DefaultContextPackBuilder] so the
 * budget/dedup passes can never evict it — its whole value is surviving the truncation that gave the
 * model amnesia in the first place. Returns null when nothing is failing (list empty ⇒ done).
 *
 * [items] are (target, assertionId, evidence) triples of the currently-failing assertions.
 */
fun buildRemainingDeltaEntry(items: List<Triple<String, String, String>>): ContextEntry? {
    if (items.isEmpty()) return null
    val content = buildString {
        append("## Remaining to finish this stage\n")
        append(
            "Make each item below true, then call stage_complete. Items disappear from this list " +
                "as you satisfy them; when it is empty the stage is done. This list is recomputed " +
                "from the filesystem — re-reading files does NOT change it, only writing/editing the " +
                "named files does. Fix exactly these, do not re-explore.\n",
        )
        items.forEach { (target, assertionId, evidence) ->
            append("- [ ] ").append(target).append(" — ").append(assertionId)
            if (evidence.isNotBlank()) append(": ").append(evidence)
            append("\n")
        }
    }.trimEnd()
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L1,
        content = content,
        sourceType = "remainingDelta",
        sourceId = "remaining-delta",
        tokenEstimate = content.length / 4,
        // #312: recomputed every turn a write lands — the single most mutable entry in the pack.
        // As SYSTEM it both sat in the weakest slot and invalidated the cached system prefix each
        // turn. USER, and appended after whichever repair mandate won (it is the completion signal,
        // not a competing instruction).
        role = EntryRole.USER,
    )
}

fun criticArtifactIds(
    events: List<StoredEvent>,
    graph: WorkflowGraph,
    stageId: StageId,
): Set<ArtifactId> {
    val cycle = events
        .mapNotNull { it.payload as? RefinementIterationEvent }
        .lastOrNull { it.cycleKey.endsWith("->${stageId.value}") } ?: return emptySet()
    val fromStage = StageId(cycle.cycleKey.substringBefore("->"))
    return graph.stages[fromStage]?.produces?.map { it.name }?.toSet() ?: emptySet()
}

fun buildArtifactKindVocabularyEntry(kinds: Collection<ArtifactKind>): ContextEntry {
    val listing = kinds.joinToString(", ") { k ->
        if (k.llmEmitted) "${k.id} (llm-emitted)" else k.id
    }
    val content = "## Available artifact kinds\n" +
        "Every stage's \"kind\" MUST be exactly one of: $listing\n" +
        "Stages that write or edit files use \"file_written\"; stages that run commands use " +
        "\"process_result\".\n" +
        "Do not invent kinds — a plan referencing an unknown kind fails to compile."
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L1,
        content = content,
        sourceType = "artifactKindVocabulary",
        sourceId = "artifact-kinds",
        tokenEstimate = content.length / 4,
        role = EntryRole.SYSTEM,
    )
}

fun buildRelevantFilesEntry(hits: List<RepoKnowledgeHit>): ContextEntry {
    val content = buildString {
        append("## Relevant files (retrieved by semantic similarity)\n")
        hits.map { it.text }.distinct().forEach { append("- ").append(it).append("\n") }
    }.trimEnd()
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L3,
        content = content,
        sourceType = "relevantFiles",
        sourceId = "repo-knowledge",
        tokenEstimate = content.length / 4,
        // #290: semantic retrieval hits are L3 reference — USER role, not folded into leading system.
        role = EntryRole.USER,
    )
}

fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
    val content = buildString {
        append("## Project profile\n")
        if (profile.about.isNotBlank()) append(profile.about).append("\n")
        if (profile.conventions.isNotEmpty()) {
            append("### Conventions\n")
            profile.conventions.forEach { append("- ").append(it).append("\n") }
        }
        if (profile.commands.isNotEmpty()) {
            append("### Commands\n")
            profile.commands.forEach { (name, cmd) -> append(name).append(": ").append(cmd).append("\n") }
        }
    }.trimEnd()
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L0,
        content = content,
        sourceType = "projectProfile",
        sourceId = "project-profile",
        tokenEstimate = content.length / 4,
        role = EntryRole.SYSTEM,
    )
}

// CLAUDE.md / AGENTS.md injected as L0 standing context (feat/backlog-burndown).
fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry {
    val content = instructions.content
    return ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L0,
        content = content,
        sourceType = "agentInstructions",
        sourceId = "agent-instructions",
        tokenEstimate = content.length / 4,
        role = EntryRole.SYSTEM,
    )
}
