"use client";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { memo, useCallback, useEffect, useId, useMemo, useState } from "react";
import { FileCaptureClass, FileChangeKind, FileDecisionAction, FileDecisionOrigin, FileDecisionScope, } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
import { toDisplayFileChange } from "@stigmer/sdk";
import { cn } from "@stigmer/theme";
import { FileChangeDiff } from "./FileChangesView.js";
import { FileKindBadge, FileLineStats } from "./FileReviewAtoms.js";
import { FilePathLink } from "./FilePathLink.js";
import { DiffSummary } from "../version-history/DiffSummary.js";
import { DecisionButton } from "../internal/DecisionButton.js";
import { InCardDecisionError } from "../internal/InCardDecisionError.js";
import { fileDecisionKey } from "./useFileReview.js";
import { changeSetReviewability, deriveEffectiveVerdicts, fileReviewability, } from "./file-review-status.js";
/** A stable empty set so the default `submittingDecisionKeys` keeps a constant ref. */
const NO_KEYS = new Set();
/** A stable empty map so the default `decisionErrors` keeps a constant ref. */
const NO_ERRORS = new Map();
/**
 * Renders a captured {@link FileChangeSet} as a **compact decision bar**: one
 * line carrying the set summary and the whole-set decision controls, with the
 * per-file detail behind an expander. What the expander holds depends on
 * {@link FileReviewCardProps.showDiffs}: the diff-rich body (default — for
 * surfaces where this card is the only review surface) or a compact file list
 * (the session thread, whose stamped edit rows already show every diff in
 * place). A set that needs per-file attention (binary acknowledgment, blocked
 * files, mid-review) starts expanded.
 *
 * File edits are reviewed as a captured workspace delta, not tool calls, so this
 * is a dedicated decision surface distinct from the message transcript and the
 * tool-approval {@link ApprovalCard}. The reviewer can decide each file
 * individually (Keep / Discard, a `FILE`-scoped decision) or act on the whole
 * set at once (Approve / Reject, a `CHANGE_SET` decision). Either way the runner
 * reconciles the exact approved bytes with hash verification.
 *
 * The unified HITL gate clears only when EVERY file has a verdict — one
 * `CHANGE_SET` decision, or a `FILE` decision per file — so a partially-decided
 * set stays actionable and deciding the last file is what resumes the turn. A
 * per-file verdict is changeable while the set is still open: clicking the other
 * option records a new decision (the backend reconcile is last-write-wins).
 *
 * A `binary-only` set (binary files are the only blocker) can be kept in one
 * action: the bulk button reads "Keep all" and carries the acknowledgment
 * (DD-17). A `blocked` set (something unavailable to review) cannot be approved
 * as a whole — the Approve button is disabled with an explanation, and the
 * per-file path is the escape: keep the reviewable files and discard the rest.
 *
 * Each non-reviewable file is labeled honestly by {@link fileReviewability}: a
 * `binary` change has no text diff to review; an `unavailable` change is one
 * whose diff isn't available at all, with the honest cause the runner recorded
 * (a secret path whose bytes were never captured, or a diff dropped to bound the
 * status — doc 15). Both are discard-only, and their Keep affordance is disabled
 * with the reason associated via `aria-describedby`. Files captured outside
 * normal git tracking (gitignored / non-git CAS) carry a small provenance badge
 * so the reviewer knows the change is not part of the repo's tracked history.
 *
 * Wrapped in `React.memo` — the streamed `FileChangeSet` reference is preserved
 * by structural sharing when unchanged, so review cards skip re-renders during
 * unrelated stream updates.
 *
 * @example
 * ```tsx
 * const { submitFileDecision, submittingDecisionKeys } = useFileReview();
 * <FileReviewCard
 *   fileChangeSet={changeSet}
 *   onSubmit={(action, opts) => submitFileDecision(executionId, changeSet.id, action, opts)}
 *   submittingDecisionKeys={submittingDecisionKeys}
 * />
 * ```
 */
export const FileReviewCard = memo(function FileReviewCard({ fileChangeSet, onSubmit, submittingDecisionKeys = NO_KEYS, decisionErrors = NO_ERRORS, interactive = true, showDiffs = true, className, }) {
    const changes = fileChangeSet.changes;
    const total = changes.length;
    const setId = fileChangeSet.id;
    // Each file's current verdict, a last-write-wins fold over the ledger-ordered
    // FILE decisions (the same precedence the runner reconcile applies). Drives
    // which per-file option reads as selected and the reviewed-count progress.
    const verdictByFileId = useMemo(() => deriveFileVerdicts(fileChangeSet.decisions), [fileChangeSet.decisions]);
    const decidedCount = verdictByFileId.size;
    // The EFFECTIVE verdicts for history (a CHANGE_SET decision covers every file
    // a FILE decision does not override — the reconcile's own precedence), so a
    // settled "Keep all" set reads every file as kept, never "Not reviewed".
    // Interactive per-file selection deliberately keeps the FILE-only fold above.
    const effectiveVerdicts = useMemo(() => deriveEffectiveVerdicts(fileChangeSet), [fileChangeSet]);
    // The set-level reviewability drives the bulk affordance. A "binary-only" set
    // (binary files are the ONLY blocker) is keepable in one acknowledged action
    // ("Keep all", DD-17); a "blocked" set (a secret/elided file with no keepable
    // bytes) can never be approved at once and must be resolved per file.
    const reviewability = changeSetReviewability(fileChangeSet);
    const incomplete = reviewability !== "complete";
    const binaryOnly = reviewability === "binary-only";
    // Per-file controls earn their space when there is more than one file OR when a
    // lone file is not reviewable — a single binary needs its per-file "Keep anyway"
    // (FILE-scope, acknowledged), which the CHANGE_SET-scoped bulk controls can't
    // offer. A single COMPLETE file stays decided once via the bar's bulk controls.
    const showPerFile = total > 1 || incomplete;
    // The bulk (CHANGE_SET) controls live on the bar for multi-file sets and for a
    // single complete file; they are hidden for a single incomplete file, whose
    // only honest decision is the per-file control in the expanded body (a single
    // binary uses its per-file "Keep anyway" — DD-16 — and a blocked lone file can
    // only be discarded).
    const showBulkControls = total > 1 || !incomplete;
    // The bar starts expanded exactly when the collapsed bar cannot honestly carry
    // the decision: an incomplete set (binary acknowledgment / blocked files need
    // their per-file context), a review already in progress, or a multi-file set
    // in compact-list mode — its honest decision surface includes the per-file
    // Keep/Discard controls, which must be visible without hunting behind the
    // expander (the docked strip bounds the height, so expanding is cheap there).
    // Diff-rich mode (`showDiffs`) deliberately stays collapsed for a complete
    // multi-file set: its expanded body is every full diff with no height cap
    // (the workflow file-review list), where "Review" is the act of opening it.
    // A settled record always starts collapsed — the transcript's stamped rows
    // already show what changed, and the expander reveals detail on demand.
    const [expanded, setExpanded] = useState(() => interactive && (incomplete || decidedCount > 0 || (total > 1 && !showDiffs)));
    const bodyId = useId();
    const wholeSetSubmitting = submittingDecisionKeys.has(setId);
    const wholeSetError = decisionErrors.get(setId) ?? null;
    // Bulk (CHANGE_SET) decision state — mirrors ApprovalCardBody: one active
    // action drives the clicked button's spinner, cleared when the RPC settles.
    const [activeAction, setActiveAction] = useState(null);
    useEffect(() => {
        if (!wholeSetSubmitting)
            setActiveAction(null);
    }, [wholeSetSubmitting]);
    const handleBulk = useCallback((action) => {
        setActiveAction(action);
        // Whole-set decision: CHANGE_SET scope, bound to the aggregate digest the
        // reviewer saw (the enforcement gate the runner re-verifies at reconcile).
        // A "Keep all" on a binary-only set carries the acknowledgment: the
        // binaries have no text diff but reconcilable bytes, so the user
        // consciously keeps the whole set (DD-17). The server honors it only when
        // every incompleteness is binary and never relaxes the digest gate.
        onSubmit?.(action, {
            scope: FileDecisionScope.CHANGE_SET,
            expectedDigest: fileChangeSet.aggregateDigest,
            acknowledgeUnreviewable: action === FileDecisionAction.APPROVE && binaryOnly,
        });
    }, [onSubmit, fileChangeSet.aggregateDigest, binaryOnly]);
    const handleFileDecide = useCallback((change, action) => {
        // Per-file decision: FILE scope correlated by the change id, bound to that
        // file's digest (echoed verbatim — the server compares it against the same
        // captured value, so it can never spuriously mismatch). A "Keep anyway" on a
        // binary file carries the acknowledgment: it has no text diff, but its exact
        // bytes are captured and reconcilable, so the user consciously keeps it
        // (DD-16). The server honors this only for a binary file and never relaxes
        // the digest gate.
        onSubmit?.(action, {
            scope: FileDecisionScope.FILE,
            fileChangeId: change.id,
            expectedDigest: change.fileDigest,
            acknowledgeUnreviewable: action === FileDecisionAction.APPROVE &&
                fileReviewability(change).kind === "binary",
        });
    }, [onSubmit]);
    // The set's aggregate `+N −M`, summed from the capture-time per-file counts
    // (linesAdded/linesRemoved are stamped by the runner with the same diff
    // algorithm the renderers use — see CapturedFileChange). A file with no
    // counts (binary, secret-withheld, or a record predating the fields)
    // contributes zero; when NO file has counts the aggregate is hidden rather
    // than shown as a dishonest "+0 −0".
    const totals = useMemo(() => {
        let additions = 0;
        let deletions = 0;
        for (const c of changes) {
            additions += c.linesAdded;
            deletions += c.linesRemoved;
        }
        return { additions, deletions };
    }, [changes]);
    // Which non-reviewable classes are present in the set — folded once (memoized
    // like `totals`, DD-010) so the blocked-set copy is accurate to what's actually
    // here (never the generic "binary or truncated").
    const blockSummary = useMemo(() => {
        let hasBinary = false;
        let hasUnavailable = false;
        for (const c of changes) {
            const r = fileReviewability(c);
            if (r.kind === "binary")
                hasBinary = true;
            else if (r.kind === "unavailable")
                hasUnavailable = true;
        }
        return { hasBinary, hasUnavailable };
    }, [changes]);
    // Associates the disabled whole-set Approve with the notice explaining why it
    // is unavailable (a11y: a disabled action is never an unexplained dead end).
    const incompleteNoticeId = useId();
    const labels = bulkLabels(total, decidedCount, binaryOnly);
    // A set the DD-28 policy kept (every change produced by a command the user
    // already approved) says so explicitly: consent was given at the command
    // gate, and the record must never read as if a human reviewed it here.
    const autoKept = !interactive && isPolicyAutoKept(fileChangeSet);
    const summary = interactive
        ? `${total} file${total === 1 ? "" : "s"} awaiting review`
        : autoKept
            ? "Kept automatically — produced by a command you approved"
            : settledSummary(total, changes, effectiveVerdicts);
    return (_jsxs("div", { 
        // Interactive: an actionable alert (a pending gate). Read-only: a passive
        // historical record, so a neutral group without the warning accent.
        role: interactive ? "alert" : "group", "aria-label": interactive
            ? `Review ${total} file change${total === 1 ? "" : "s"}`
            : `${total} file change${total === 1 ? "" : "s"}`, className: cn("rounded-lg border border-border-prominent", interactive && "border-l-2 border-l-warning", className), children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 px-2.5 py-1.5 text-xs", children: [_jsx("span", { className: "shrink-0 font-medium text-foreground", children: interactive ? "Review file changes" : "File changes" }), _jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: "min-w-0 truncate text-muted-foreground", children: summary }), _jsx(FileLineStats, { linesAdded: totals.additions, linesRemoved: totals.deletions })] }), interactive && showBulkControls && (_jsxs(_Fragment, { children: [_jsx(DecisionButton, { label: labels.approve, variant: "primary", onClick: () => handleBulk(FileDecisionAction.APPROVE), isActive: activeAction === FileDecisionAction.APPROVE, isSubmitting: wholeSetSubmitting, disabled: incomplete && !binaryOnly, ariaDescribedby: incomplete ? incompleteNoticeId : undefined, cursorTarget: "file-review-approve" }), _jsx(DecisionButton, { label: labels.reject, variant: "danger", onClick: () => handleBulk(FileDecisionAction.REJECT), isActive: activeAction === FileDecisionAction.REJECT, isSubmitting: wholeSetSubmitting, cursorTarget: "file-review-reject" })] })), _jsxs("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, onClick: () => setExpanded((v) => !v), "data-cursor-target": "file-review-expander", className: cn("inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1", "text-xs font-medium text-muted-foreground transition-colors", "hover:bg-accent-hover hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), children: [expanded ? "Hide" : !interactive ? "Show" : showDiffs ? "Review" : "Files", _jsx(ExpanderChevron, { expanded: expanded })] })] }), interactive && incomplete && showBulkControls && (_jsx("p", { id: incompleteNoticeId, className: "px-2.5 pb-1.5 text-[11px] italic text-muted-foreground", children: incompleteNotice(showPerFile, blockSummary, binaryOnly) })), interactive && wholeSetError && (_jsx("div", { className: "px-2.5 pb-1.5", children: _jsx(InCardDecisionError, { error: wholeSetError, leadIn: "submit decision", cursorTarget: "file-review-error" }) })), expanded && (_jsxs("div", { id: bodyId, className: "space-y-2 border-t border-border-muted px-3 py-2.5", children: [showDiffs && (_jsx(DiffSummary, { fileCount: total, additions: totals.additions, deletions: totals.deletions })), !interactive
                        ? // Settled: every file paired with its committed EFFECTIVE
                            // verdict (a bulk decision covers files without their own), no
                            // controls — a record of what changed and how it was decided.
                            changes.map((change) => (_jsx(SettledFileRow, { change: change, verdict: effectiveVerdicts.get(change.id) ?? null, showDiffs: showDiffs }, change.id)))
                        : showPerFile
                            ? changes.map((change) => (_jsx(FileChangeReviewRow, { change: change, verdict: verdictByFileId.get(change.id) ?? null, isSubmitting: submittingDecisionKeys.has(fileDecisionKey(setId, change.id)) ||
                                    wholeSetSubmitting, error: decisionErrors.get(fileDecisionKey(setId, change.id)) ?? null, onDecide: handleFileDecide, showDiffs: showDiffs }, change.id)))
                            : // A single complete file decided via the bar's bulk controls:
                                // its detail is informational, with no per-file control.
                                changes.map((change) => showDiffs ? (_jsxs("div", { className: "space-y-1.5", children: [_jsx(CaptureBadge, { change: change }), _jsx(CapturedChangeDiff, { change: change })] }, change.id)) : (_jsx(FileListRow, { change: change }, change.id))), interactive && showPerFile && (_jsx(ReviewProgress, { decided: decidedCount, total: total }))] }))] }));
});
/**
 * Whether the set was kept by the approved-command auto-keep policy (DD-28):
 * some decision carries origin POLICY_APPROVED_COMMAND. Origin is audit
 * provenance — the record label derives from it so an automatic keep is never
 * presented as a human review.
 */
function isPolicyAutoKept(fileChangeSet) {
    return fileChangeSet.decisions.some((d) => d.origin === FileDecisionOrigin.POLICY_APPROVED_COMMAND);
}
/**
 * The settled bar's one-line history: verdict counts folded from the effective
 * decisions ("2 kept · 1 discarded"), with an honest "not reviewed" bucket for
 * files a terminated set never decided. Falls back to a plain changed-count
 * when nothing was decided at all.
 */
function settledSummary(total, changes, effectiveVerdicts) {
    let kept = 0;
    let discarded = 0;
    for (const c of changes) {
        const v = effectiveVerdicts.get(c.id);
        if (v === FileDecisionAction.APPROVE)
            kept++;
        else if (v === FileDecisionAction.REJECT)
            discarded++;
    }
    const undecided = total - kept - discarded;
    if (kept === 0 && discarded === 0) {
        return `${total} file${total === 1 ? "" : "s"} changed`;
    }
    const parts = [];
    if (kept > 0)
        parts.push(`${kept} kept`);
    if (discarded > 0)
        parts.push(`${discarded} discarded`);
    if (undecided > 0)
        parts.push(`${undecided} not reviewed`);
    return parts.join(" · ");
}
/** The bar expander's disclosure chevron (rotates when open). */
function ExpanderChevron({ expanded }) {
    return (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 10 10", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", className: cn("shrink-0 transition-transform duration-150", expanded && "rotate-90"), "aria-hidden": "true", children: _jsx("path", { d: "M3.5 2L6.5 5L3.5 8" }) }));
}
/**
 * One reviewable file with its Keep/Discard control. The decision machinery
 * (verdict radiogroup, block-reason note, in-card error) is identical in both
 * body modes; only the file presentation varies — the bounded diff, or the
 * compact {@link FileListRow} line with the control trailing it.
 */
const FileChangeReviewRow = memo(function FileChangeReviewRow({ change, verdict, isSubmitting, error, onDecide, showDiffs, }) {
    // One classification per change drives BOTH the disabled Keep and its reason,
    // so the two can never disagree (single source of truth).
    const reviewability = fileReviewability(change);
    const blocked = reviewability.kind !== "reviewable";
    const reasonId = useId();
    const verdictControl = (_jsx(FileVerdictControl, { change: change, reviewability: reviewability, verdict: verdict, isSubmitting: isSubmitting, onDecide: onDecide, describedById: blocked ? reasonId : undefined }));
    return (_jsxs("div", { className: "space-y-1.5", children: [showDiffs ? (_jsxs(_Fragment, { children: [_jsx(CaptureBadge, { change: change }), _jsx(CapturedChangeDiff, { change: change }), verdictControl] })) : (_jsx(FileListRow, { change: change, trailing: verdictControl })), blocked && _jsx(BlockReasonNote, { reviewability: reviewability, id: reasonId }), error && (_jsx(InCardDecisionError, { error: error, leadIn: "save", cursorTarget: "file-review-file-error" }))] }));
});
/**
 * A read-only file row for a settled change set: the capture provenance badge
 * and a static verdict badge, with the before/after diff in diff mode or the
 * compact list line otherwise. No decision controls — the verdict is history,
 * not an action. Used by the read-only {@link FileReviewCard}
 * (`interactive={false}`) for decided/reconciled sets and terminal executions.
 */
const SettledFileRow = memo(function SettledFileRow({ change, verdict, showDiffs, }) {
    if (!showDiffs) {
        return _jsx(FileListRow, { change: change, trailing: _jsx(VerdictBadge, { verdict: verdict }) });
    }
    return (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CaptureBadge, { change: change }), _jsx(VerdictBadge, { verdict: verdict })] }), _jsx(CapturedChangeDiff, { change: change })] }));
});
// ---------------------------------------------------------------------------
// CapturedChangeDiff — a captured change's bounded diff (showDiffs mode)
// ---------------------------------------------------------------------------
/**
 * Projects a {@link CapturedFileChange} onto the display `FileChange` and
 * renders its bounded diff — the one place the diff body renders, shared by the
 * interactive, settled, and single-file branches of the diff-mode body.
 */
function CapturedChangeDiff({ change }) {
    const adapted = useMemo(() => toDisplayFileChange(change), [change]);
    return _jsx(FileChangeDiff, { change: adapted, bounded: true });
}
// ---------------------------------------------------------------------------
// FileListRow — one file as a compact list line (list mode)
// ---------------------------------------------------------------------------
/**
 * One file of the change set as a compact list line: kind letter, linked path,
 * `+N −M` stats, provenance badge, and an optional trailing slot (the verdict
 * control while reviewing; the committed verdict once settled).
 *
 * The path renders through `FilePathLink` — the platform-wide path idiom — so
 * a reviewer can jump from the list to the file itself (the session viewer
 * routes the click into the panel's diff-first viewer; GitHub/copy is the
 * fallback) without leaving the decision surface. Deciding and navigating
 * coexist: the link stops click propagation, so opening a file never fights
 * the row's Keep/Discard controls. A rename shows its source path so the move
 * reads in one line.
 */
function FileListRow({ change, trailing, }) {
    const path = change.pathAfter || change.pathBefore;
    const renamedFrom = change.kind === FileChangeKind.RENAME && change.pathBefore !== change.pathAfter
        ? change.pathBefore
        : "";
    return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", "data-cursor-target": "file-review-list-row", children: [_jsx(FileKindBadge, { kind: change.kind }), renamedFrom && (_jsxs("span", { className: "min-w-0 truncate font-mono text-xs text-muted-foreground-faint", children: [renamedFrom, "\u00A0\u2192\u00A0"] })), _jsx(FilePathLink, { path: path, dirDisplay: "dim", className: "min-w-0 flex-1 text-xs" }), _jsx(FileLineStats, { linesAdded: change.linesAdded, linesRemoved: change.linesRemoved }), _jsx(CaptureBadge, { change: change }), trailing] }));
}
/**
 * A change's committed verdict as a static badge: APPROVE reads "Kept" (the
 * bytes were reconciled into the workspace), REJECT reads "Discarded" (reverted
 * to baseline), and an undecided change (a set terminated mid-review) reads "Not
 * reviewed". Uses the same diff add/remove tokens the card uses elsewhere, so it
 * introduces no new token.
 */
function VerdictBadge({ verdict }) {
    if (verdict === FileDecisionAction.APPROVE) {
        return (_jsx("span", { className: "shrink-0 text-[11px] font-medium text-diff-added-fg", children: "Kept" }));
    }
    if (verdict === FileDecisionAction.REJECT) {
        return (_jsx("span", { className: "shrink-0 text-[11px] font-medium text-diff-removed-fg", children: "Discarded" }));
    }
    return (_jsx("span", { className: "shrink-0 text-[11px] italic text-muted-foreground", children: "Not reviewed" }));
}
/**
 * The per-file decision affordance: a two-option segmented control modeled as a
 * `radiogroup` (the established idiom across the SDK — Keep XOR Discard is a
 * persistent, mutually-exclusive choice because the card stays visible after
 * each file decision). The committed verdict reads as `aria-checked`; clicking
 * the other option flips it (a new last-write-wins decision).
 *
 * `Keep` behavior by reviewability: a `reviewable` file keeps normally; a
 * `binary` file has no text diff but reconcilable bytes, so `Keep` is enabled as
 * an explicit "Keep anyway" that carries the acknowledgment (DD-16); an
 * `unavailable` file (secret-withheld / size-elided) has no keepable bytes, so
 * `Keep` stays disabled — it can only be discarded. `describedById` associates
 * the group with the note explaining the binary/unavailable case.
 */
function FileVerdictControl({ change, reviewability, verdict, isSubmitting, onDecide, describedById, }) {
    // Optimistic: show the clicked option immediately; the server's decisions
    // projection then confirms it (or, on failure, it reverts to `verdict`).
    const [pending, setPending] = useState(null);
    useEffect(() => {
        if (!isSubmitting)
            setPending(null);
    }, [isSubmitting]);
    const effective = pending ?? verdict;
    const path = change.pathAfter || change.pathBefore;
    // A binary file is keepable via an explicit acknowledgment; only a truly
    // unavailable diff (no captured bytes) leaves Keep disabled.
    const isBinary = reviewability.kind === "binary";
    const keepDisabled = reviewability.kind === "unavailable";
    const keepLabel = isBinary ? "Keep anyway" : "Keep";
    const keepAccessibleLabel = isBinary
        ? `Keep ${path} anyway (binary — no text diff)`
        : `Keep ${path}`;
    const decide = useCallback((action) => {
        if (isSubmitting)
            return;
        setPending(action);
        onDecide(change, action);
    }, [isSubmitting, onDecide, change]);
    return (_jsxs("div", { role: "radiogroup", "aria-label": `Decision for ${path}`, "aria-describedby": describedById, className: "flex items-center gap-1.5", children: [_jsx(VerdictOption, { label: keepLabel, accessibleLabel: keepAccessibleLabel, tone: "keep", selected: effective === FileDecisionAction.APPROVE, busy: isSubmitting && pending === FileDecisionAction.APPROVE, disabled: isSubmitting || keepDisabled, onClick: () => decide(FileDecisionAction.APPROVE) }), _jsx(VerdictOption, { label: "Discard", accessibleLabel: `Discard ${path}`, tone: "discard", selected: effective === FileDecisionAction.REJECT, busy: isSubmitting && pending === FileDecisionAction.REJECT, disabled: isSubmitting, onClick: () => decide(FileDecisionAction.REJECT) })] }));
}
// Quiet at rest (ghost), tinted when selected — the verdict is signalled by both
// fill AND tone (not color alone), and every property flows through `--stgm-*`
// tokens with no opacity modifiers.
const VERDICT_BASE = cn("inline-flex items-center justify-center gap-1.5 rounded-md border px-2.5 py-1", "text-xs font-medium transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50");
const VERDICT_REST = "border-border text-muted-foreground hover:bg-accent-hover hover:text-foreground";
const VERDICT_SELECTED = {
    keep: "border-success bg-success-subtle text-success",
    discard: "border-destructive bg-destructive-subtle text-destructive",
};
function VerdictOption({ label, accessibleLabel, tone, selected, busy, disabled, onClick, }) {
    return (_jsxs("button", { type: "button", role: "radio", "aria-checked": selected, "aria-label": accessibleLabel, disabled: disabled, onClick: onClick, "data-cursor-target": `file-review-${tone}`, className: cn(VERDICT_BASE, selected ? VERDICT_SELECTED[tone] : VERDICT_REST), children: [busy ? _jsx(SpinnerIcon, {}) : null, label] }));
}
// ---------------------------------------------------------------------------
// CaptureBadge — provenance for a file captured outside normal git tracking
// ---------------------------------------------------------------------------
/** Non-default provenance the reviewer should be told about, with its a11y text. */
const PROVENANCE = {
    [FileCaptureClass.GIT_IGNORED_CAPTURED]: {
        label: "gitignored",
        aria: "This file is ignored by git; the change was captured for review",
    },
    [FileCaptureClass.NON_GIT_CAS]: {
        label: "outside git",
        aria: "This file is outside the git repository; the change was captured for review",
    },
};
/**
 * A small provenance pill for a file captured outside normal git tracking —
 * gitignored or non-git (CAS). Renders nothing for ordinary git-tracked (or
 * untracked-but-new) files, so the badge appears only when it carries real
 * signal: this change is not part of the repo's tracked history.
 */
function CaptureBadge({ change }) {
    const provenance = PROVENANCE[change.captureClass];
    if (!provenance)
        return null;
    return (_jsx("span", { className: "inline-flex w-fit items-center rounded border border-border bg-muted-subtle px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground", "aria-label": provenance.aria, "data-cursor-target": "file-review-capture", children: provenance.label }));
}
// ---------------------------------------------------------------------------
// BlockReasonNote — why a file's Keep is unavailable (associated via aria)
// ---------------------------------------------------------------------------
/**
 * The in-place explanation for a non-reviewable file, associated with the verdict
 * control via `aria-describedby`. Honest per {@link fileReviewability}: a `binary`
 * change has no text diff but is keepable-as-bytes (an explicit "Keep anyway"); an
 * `unavailable` change's diff cannot be shown at all and is discard-only, with the
 * specific cause the runner recorded (secret-withheld vs size-dropped, doc 15). A
 * reviewable file renders no note.
 */
function BlockReasonNote({ reviewability, id, }) {
    const text = blockReasonText(reviewability);
    if (!text)
        return null;
    return (_jsx("p", { id: id, className: "text-[11px] italic text-muted-foreground", "data-cursor-target": "file-review-block-reason", children: text }));
}
/** The per-file reason copy for a non-reviewable change (null when reviewable). */
function blockReasonText(reviewability) {
    switch (reviewability.kind) {
        case "binary":
            return "Binary file — no text diff to review. Keep it as-is (its exact bytes are applied) or discard it.";
        case "unavailable":
            switch (reviewability.reason) {
                case "secret":
                    return "Contents withheld — this path looks like a secret, so it was never captured for review and can only be discarded.";
                case "size":
                    return "Diff too large to display — the full contents were elided to stay within limits, so this change can only be discarded.";
                default:
                    return "The full diff isn't available to review, so this change can only be discarded.";
            }
        default:
            return null;
    }
}
/**
 * The whole-set incompleteness copy, accurate to which non-reviewable classes are
 * present (never the generic "binary or truncated"). Unavailable is reported
 * ahead of binary because it is the stronger "nothing to see" signal.
 *
 * A `binaryOnly` set is the keepable case (DD-17): binary files are the only
 * blocker, so the copy explains that "Keep all" is available rather than saying
 * the set can't be approved. A `blocked` set (something unavailable) keeps the
 * discard-oriented copy.
 */
function incompleteNotice(showPerFile, summary, binaryOnly) {
    if (binaryOnly) {
        // Every non-binary file is reviewable; only binaries have no text diff.
        return showPerFile
            ? "This set includes binary files with no text diff. Keep all keeps every file as-is (their exact bytes are applied), or decide each file below."
            : "This is a binary change with no text diff. Keep it as-is (its exact bytes are applied) or discard it.";
    }
    if (showPerFile) {
        if (summary.hasUnavailable) {
            return "Part of this change isn't available to review, so the whole set can't be approved — keep the reviewable files and discard the rest.";
        }
        if (summary.hasBinary) {
            return "This set includes a binary change with no text diff, so it can't be approved at once — decide each file below (keep the reviewable ones; keep or discard the binary).";
        }
        return "Some files can't be reviewed completely, so the whole set can't be approved at once — keep the reviewable files and discard the rest.";
    }
    if (summary.hasUnavailable) {
        return "This change isn't available to review, so it can't be approved — it can only be discarded.";
    }
    if (summary.hasBinary) {
        return "This is a binary change with no text diff, so it can't be approved as a complete change — it can only be discarded.";
    }
    return "This change can't be reviewed completely, so it can't be approved as a complete change.";
}
// ---------------------------------------------------------------------------
// ReviewProgress — how many files still need a verdict before the turn resumes
// ---------------------------------------------------------------------------
function ReviewProgress({ decided, total }) {
    return (_jsxs("p", { role: "status", className: "text-[11px] text-muted-foreground", children: [decided, " of ", total, " files reviewed", decided > 0 && decided < total && " — decide the rest to continue"] }));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Fold the ledger-ordered decisions into each file's current verdict, last
 * write wins (the precedence the runner reconcile applies). Only FILE-scoped
 * decisions are per-file; a CHANGE_SET decision cannot coexist with an
 * AWAITING_REVIEW set, so it is ignored defensively.
 */
function deriveFileVerdicts(decisions) {
    const byFileId = new Map();
    for (const d of decisions) {
        if (d.scope === FileDecisionScope.FILE && d.fileChangeId) {
            byFileId.set(d.fileChangeId, d.action);
        }
    }
    return byFileId;
}
/**
 * The whole-set action labels, sharpened by file count and review progress. A
 * `binaryOnly` set (DD-17) reads as "Keep all" rather than "Approve all": the
 * bulk keep carries the binary acknowledgment, and "Keep" is the same verb the
 * per-file binary control uses ("Keep anyway").
 */
function bulkLabels(total, decided, binaryOnly) {
    const approveVerb = binaryOnly ? "Keep" : "Approve";
    if (total <= 1)
        return { approve: approveVerb, reject: "Reject" };
    if (decided > 0 && decided < total) {
        return { approve: `${approveVerb} remaining`, reject: "Reject remaining" };
    }
    return { approve: `${approveVerb} all`, reject: "Reject all" };
}
// ---------------------------------------------------------------------------
// Inline SVG icon
// ---------------------------------------------------------------------------
function SpinnerIcon() {
    return (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", className: "animate-spin", "aria-hidden": "true", children: _jsx("path", { d: "M6 1.5A4.5 4.5 0 1 1 1.5 6", strokeLinecap: "round" }) }));
}
//# sourceMappingURL=FileReviewCard.js.map