// The bounded model/validate/scope-guard retry loop (ADR-0009 D6/D10). Each attempt builds the
// prompt, calls the injected ModelPort, parses the output, and classifies the result. The KEY
// behavioural difference from #8: an EMPTY diff with a root-cause hypothesis is a VALID
// investigation-only outcome (NOT a retry); only a malformed/oversized/out-of-scope NON-empty patch
// retries. The change budget (D6 bound 1) is enforced by passing a workflow-owned PatchLimits into
// #6 validatePatch; the sensitive-path guard (D6 bound 2) runs on validation.files[].path. The
// model call is the one IO boundary here; its failure propagates to the workflow catch boundary.
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
import { validatePatch } from "@oscharko-dev/keiko-tools";
import { governedPatchRejectionCode } from "../governed-handoff.js";
import { isTestPath } from "../unit-tests/conventions.js";
import { isSensitivePath } from "./guard.js";
import { parseBugModelOutputCandidates } from "./parse.js";
import { buildBugPrompt } from "./prompt.js";
import { patchLimitsFrom, } from "./internal.js";
// The sensitive-path scope guard (D6 bound 2): every changed path must NOT be sensitive. Returns
// "out-of-scope" when any path is traversal/.github/.husky/lockfile, else undefined. The change
// budget (bound 1) is enforced by #6 itself via the limits passed to validatePatch.
function scopeGuard(validation) {
    const offending = validation.files.some((file) => isSensitivePath(file.path));
    return offending ? "out-of-scope" : undefined;
}
function emitValidation(state, validation, code) {
    const ok = code === undefined && validation.ok;
    state.emitter.emit({
        type: "bug:patch:validated",
        ok,
        patchBytes: validation.totalBytes,
        filesChanged: validation.files.length,
        ...(ok ? {} : { rejectionCode: code ?? validation.reasons[0]?.code }),
    });
}
function hypothesisOf(parsed) {
    return {
        rootCause: parsed.rootCause,
        regressionTestStrategy: parsed.regressionTestStrategy,
        uncertainty: parsed.uncertainty,
        confidence: parsed.confidence,
    };
}
// True when the parsed output carries at least one prose section (any hypothesis content).
function hasProse(parsed) {
    return (parsed.rootCause !== undefined ||
        parsed.regressionTestStrategy !== undefined ||
        parsed.uncertainty !== undefined ||
        parsed.confidence !== undefined);
}
async function callModel(state, messages, attempt, contextBytes) {
    state.progress.modelCallCount = Math.max(state.progress.modelCallCount, attempt);
    state.emitter.emit({ type: "bug:model:call:started", attempt, contextBytes });
    const response = await state.deps.model.call({ modelId: state.input.modelId, messages }, state.signal);
    state.emitter.emit({
        type: "bug:model:call:completed",
        attempt,
        finishReason: response.finishReason,
        promptTokens: response.usage.promptTokens,
        completionTokens: response.usage.completionTokens,
        latencyMs: response.usage.latencyMs,
    });
    return response.content;
}
function classifyEmptyDiff(parsed) {
    // Empty diff + a hypothesis -> investigation-only (NOT a retry). Empty diff + no prose -> retry.
    if (hasProse(parsed)) {
        return {
            accepted: undefined,
            investigationOnly: hypothesisOf(parsed),
            rejectionCode: undefined,
            rejectionReason: undefined,
        };
    }
    return {
        accepted: undefined,
        investigationOnly: undefined,
        rejectionCode: "empty",
        rejectionReason: "empty: no diff or root-cause hypothesis was provided",
    };
}
function validationRejectionReason(validation, code) {
    if (code === undefined) {
        return undefined;
    }
    const message = validation.reasons[0]?.message;
    if (message !== undefined) {
        return `${code}: ${message}`;
    }
    if (code === "test-only") {
        return "test-only: a source bug fix must include a minimal non-test source change; tests may be added in the same diff";
    }
    const conflict = validation.conflicts[0];
    if (conflict !== undefined) {
        return `${code}: ${conflict.path} hunk#${String(conflict.hunkIndex)} ${conflict.reason}`;
    }
    return code;
}
function sourceBugRequiresSourcePatch(workspace, report, evidence) {
    const paths = [...(report.targetFiles ?? []), ...evidence.frames.map((frame) => frame.file)];
    return paths.some((path) => !isTestPath(workspace, path));
}
function semanticGuard(workspace, validation, requiresSourcePatch, governedHandoff) {
    if (validation.files.length === 0) {
        return "malformed";
    }
    const scopeCode = scopeGuard(validation);
    if (scopeCode !== undefined) {
        return scopeCode;
    }
    const governedCode = governedPatchRejectionCode(governedHandoff, validation);
    if (governedCode !== undefined) {
        return governedCode;
    }
    return requiresSourcePatch && validation.files.every((file) => isTestPath(workspace, file.path))
        ? "test-only"
        : undefined;
}
function emptyParsedOutput() {
    return {
        diff: "",
        rootCause: undefined,
        regressionTestStrategy: undefined,
        uncertainty: undefined,
        confidence: undefined,
    };
}
function classifyValidated(workspace, parsed, validation, requiresSourcePatch, governedHandoff) {
    const guardCode = validation.ok
        ? semanticGuard(workspace, validation, requiresSourcePatch, governedHandoff)
        : validation.reasons[0]?.code;
    if (validation.ok && guardCode === undefined) {
        const accepted = {
            diff: parsed.diff,
            validation,
            hypothesis: hypothesisOf(parsed),
        };
        return {
            accepted,
            investigationOnly: undefined,
            rejectionCode: undefined,
            rejectionReason: undefined,
        };
    }
    const rejectionCode = guardCode ?? "malformed";
    return {
        accepted: undefined,
        investigationOnly: undefined,
        rejectionCode,
        rejectionReason: validationRejectionReason(validation, rejectionCode),
    };
}
function validateCandidate(state, workspace, parsed, requiresSourcePatch) {
    const validation = validatePatch(workspace, parsed.diff, {
        fs: state.deps.fs ?? nodeWorkspaceFs,
        limits: patchLimitsFrom(state.limits),
    });
    const result = classifyValidated(workspace, { ...parsed, diff: validation.normalizedDiff ?? parsed.diff }, validation, requiresSourcePatch, state.deps.workflowHandoff);
    return { result, validation };
}
function classifyPatchCandidates(state, workspace, report, evidence, candidates) {
    const patchCandidates = candidates.filter((candidate) => candidate.diff.length > 0);
    if (patchCandidates.length === 0) {
        return classifyEmptyDiff(candidates[0] ?? emptyParsedOutput());
    }
    const requiresSourcePatch = sourceBugRequiresSourcePatch(workspace, report, evidence);
    let last;
    for (const parsed of patchCandidates) {
        const next = validateCandidate(state, workspace, parsed, requiresSourcePatch);
        if (next.result.accepted !== undefined) {
            emitValidation(state, next.validation, next.result.rejectionCode);
            return next.result;
        }
        last = next;
    }
    if (last === undefined) {
        return classifyEmptyDiff(emptyParsedOutput());
    }
    emitValidation(state, last.validation, last.result.rejectionCode);
    return last.result;
}
async function attemptOnce(state, workspace, report, evidence, pack, attempt, rejectionReason) {
    const messages = buildBugPrompt(report, evidence, pack, workspace.testFramework, rejectionReason, state.memoryPromptText);
    const content = await callModel(state, messages, attempt, pack.usedBytes);
    const candidates = parseBugModelOutputCandidates(content);
    state.emitter.emit({
        type: "bug:rootcause:proposed",
        hasPatch: candidates.some((candidate) => candidate.diff.length > 0),
        ...(candidates[0]?.confidence === undefined ? {} : { confidence: candidates[0].confidence }),
    });
    return classifyPatchCandidates(state, workspace, report, evidence, candidates);
}
// True when the attempt produced a terminal outcome (accepted patch or investigation-only); a
// retryable rejection is the only non-terminal case.
function isTerminal(result) {
    return result.accepted !== undefined || result.investigationOnly !== undefined;
}
export async function runBugModelLoop(state, workspace, report, evidence, pack) {
    let modelCallCount = 0;
    let patchRetryCount = 0;
    let rejectionReason;
    let lastRejectionCode;
    while (modelCallCount < state.limits.maxModelCalls &&
        patchRetryCount <= state.limits.maxRetries) {
        modelCallCount += 1;
        const r = await attemptOnce(state, workspace, report, evidence, pack, modelCallCount, rejectionReason);
        if (isTerminal(r)) {
            return {
                accepted: r.accepted,
                investigationOnly: r.investigationOnly,
                modelCallCount,
                patchRetryCount,
                lastRejectionCode: undefined,
            };
        }
        patchRetryCount += 1;
        state.progress.patchRetryCount = patchRetryCount;
        lastRejectionCode = r.rejectionCode;
        rejectionReason = r.rejectionReason ?? r.rejectionCode;
    }
    return {
        accepted: undefined,
        investigationOnly: undefined,
        modelCallCount,
        patchRetryCount,
        lastRejectionCode,
    };
}
