/**
 * Loopback review-decision mutation route.
 *
 * POST /workspaces/{wsId}/review-queue/{reviewId}/decision
 *
 * Records the terminal step of the local-first core loop — the human review
 * decision (approve / request-changes / revoke / defer) — directly on the
 * bridge's own `review_decisions` storage. The decision binds to the
 * review-queue item id (`review_queue.id`, surfaced as the projection item id),
 * never to a proof-card/run id. Authorized behind the least-privilege
 * `bridge:review` scope (via the route table) and workspace authorization.
 *
 * Fail-closed: the body is validated against a finite decision taxonomy with
 * bounded, sanitized labels; the target review item must exist in the same
 * sanitized projection the UI consumes; and only a bounded projection of the
 * decided item is returned (no provider/audit ids, no raw fields).
 */
import { createHash, randomUUID } from "node:crypto";
import { isReviewDecisionKind, } from "../domain/index.js";
import { isUnsafePublicString } from "../runs/runProjectionContract.js";
import { ReviewProjectionService } from "../runs/reviewProjectionService.js";
import { isRecord } from "../utils/json.js";
import { authorizeWorkspace } from "./authGuards.js";
import { PUBLIC_IDENTIFIER_SCAN_OPTIONS, writeJson } from "./publicResponse.js";
import { readJsonBody } from "./requestParsing.js";
import { firstCapture, matchPath, secondCapture } from "./routeMatching.js";
// The review projection caps public labels at 200 chars and drops unsafe
// strings, so bounding writes at the same limit guarantees every label we
// accept round-trips through the read projection rather than being silently
// nulled out on the next read.
const MAX_DECISION_LABEL_LENGTH = 200;
const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]+$/u;
// Reject C0/C7F control characters in labels so nothing unprintable reaches
// storage (the same control-character class the redaction boundary strips).
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F]/u;
const REVIEW_DECISION_ROUTE_PATTERN = /^\/workspaces\/([^/]+)\/review-queue\/([^/]+)\/decision$/;
export async function handleReviewDecisionRoute(context) {
    const { app, auth, request, response, url } = context;
    if (request.method !== "POST")
        return false;
    const match = matchPath(url.pathname, REVIEW_DECISION_ROUTE_PATTERN);
    if (!match)
        return false;
    const workspaceId = firstCapture(match);
    const reviewId = secondCapture(match);
    if (!authorizeWorkspace(response, auth, workspaceId))
        return true;
    const body = await readJsonBody(request);
    if (!isRecord(body)) {
        writeJson(response, 400, {
            error: "invalid_request",
            message: "Expected JSON body.",
        });
        return true;
    }
    if (!isReviewDecisionKind(body.decision)) {
        writeJson(response, 400, {
            error: "invalid_request",
            message: "decision must be one of approve, request-changes, revoke, defer.",
        });
        return true;
    }
    const decision = body.decision;
    const reasonCode = readOptionalLabel(body.reasonCode);
    if (reasonCode.state === "invalid") {
        writeJson(response, 400, {
            error: "invalid_request",
            message: "reasonCode must be a bounded, sanitized label.",
        });
        return true;
    }
    const noteLabel = readOptionalLabel(body.noteLabel);
    if (noteLabel.state === "invalid") {
        writeJson(response, 400, {
            error: "invalid_request",
            message: "noteLabel must be a bounded, sanitized label.",
        });
        return true;
    }
    const idempotencyKey = readOptionalIdempotencyKey(body.idempotencyKey);
    if (idempotencyKey.state === "invalid") {
        writeJson(response, 400, {
            error: "invalid_request",
            message: "idempotencyKey must be a bounded token of [A-Za-z0-9_-].",
        });
        return true;
    }
    // The decision binds to the review-queue item id. Validate existence against
    // the same sanitized projection the UI consumes — you can only decide on a
    // review the queue actually surfaces. Without this guard an INSERT OR IGNORE
    // against a non-existent review_id would silently no-op (foreign key miss)
    // and we would falsely report success.
    const projectionService = new ReviewProjectionService(app.localStore);
    const existing = projectionService
        .listReviewQueue(workspaceId)
        .items.find((item) => item.id === reviewId);
    if (!existing) {
        writeJson(response, 404, {
            error: "not_found",
            message: "Review item was not found in this workspace.",
        });
        return true;
    }
    const decisionId = deriveDecisionId(workspaceId, reviewId, idempotencyKey.state === "ok" ? idempotencyKey.value : undefined);
    const decidedAt = new Date().toISOString();
    app.localStore.insertReviewDecision({
        id: decisionId,
        workspaceId,
        reviewId,
        decision,
        ...(reasonCode.state === "ok" ? { reasonCode: reasonCode.value } : {}),
        ...(noteLabel.state === "ok" ? { noteLabel: noteLabel.value } : {}),
        decidedAt,
    });
    app.audit.append({
        type: "review.decision.recorded",
        severity: "info",
        message: "Local review decision recorded.",
        metadata: {
            workspaceId,
            reviewId,
            decisionId,
            decision,
            ...(reasonCode.state === "ok" ? { reasonCode: reasonCode.value } : {}),
            source: auth.kind === "local-ui-session" ? "local_ui" : "bridge_api",
        },
    });
    // Re-derive the bounded projection so the response reflects the recorded
    // decision (latest-per-review wins). The same redaction boundary that guards
    // the read route guards this response.
    const refreshed = projectionService.listReviewQueue(workspaceId);
    const item = refreshed.items.find((entry) => entry.id === reviewId) ?? existing;
    writeJson(response, 200, {
        contractVersion: "v1",
        generatedAt: refreshed.generatedAt,
        item,
    }, {
        surface: "workspace.review-queue.decision",
        scanOptions: PUBLIC_IDENTIFIER_SCAN_OPTIONS,
    });
    return true;
}
function readOptionalLabel(value) {
    if (value === undefined || value === null)
        return { state: "absent" };
    if (typeof value !== "string")
        return { state: "invalid" };
    const trimmed = value.trim();
    if (trimmed.length === 0 || trimmed.length > MAX_DECISION_LABEL_LENGTH) {
        return { state: "invalid" };
    }
    if (CONTROL_CHARACTER_PATTERN.test(trimmed))
        return { state: "invalid" };
    // Fail closed on anything the public projection would treat as unsafe (paths,
    // tokens, secret-like material) so it never reaches storage.
    if (isUnsafePublicString(trimmed))
        return { state: "invalid" };
    return { state: "ok", value: trimmed };
}
function readOptionalIdempotencyKey(value) {
    if (value === undefined || value === null)
        return { state: "absent" };
    if (typeof value !== "string")
        return { state: "invalid" };
    const trimmed = value.trim();
    if (trimmed.length === 0 || trimmed.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
        return { state: "invalid" };
    }
    if (!IDEMPOTENCY_KEY_PATTERN.test(trimmed))
        return { state: "invalid" };
    return { state: "ok", value: trimmed };
}
/**
 * Server-generates the decision id. When the client supplies a clean
 * idempotency key the id is derived deterministically from
 * (workspaceId, reviewId, key) so a retried submission collapses to the same
 * row under the table's INSERT OR IGNORE; otherwise a fresh random id is used.
 * The key never appears in the response or storage in raw form.
 */
function deriveDecisionId(workspaceId, reviewId, idempotencyKey) {
    if (!idempotencyKey)
        return `revdec_${randomUUID()}`;
    const digest = createHash("sha256")
        .update(`${workspaceId}\n${reviewId}\n${idempotencyKey}`, "utf8")
        .digest("hex")
        .slice(0, 40);
    return `revdec_${digest}`;
}
