/** * Classifying a turn's copy buttons. * * The turn's "copy reply" button used to be identified by whether its label string * contained `copy-turn-action-button` -- that is, by the presence of a `data-testid`. But * the button does not always carry one: measured on a live turn, a reply containing a code * block has TWO copy buttons, and the code one is a bare `aria-label="复制"` with no * testid at all, while a turn whose action bar renders differently can show the reply copy * as a bare `复制` too. * * When that happens the button falls into `other`, which carries no `expectedText`, so the * authorship check that rejects clipboard content from the wrong source has nothing to * compare against. The plan is then reported as "no plan text" even though the reply is * complete and on screen -- the failure that filed BUG-003. * * So classification goes by what the label means, not by whether a test id is attached. * The rules are here, as pure functions, rather than in the injected page script: the * script is a string, nothing type-checks or tests it, and this is exactly the kind of * logic that was wrong for a long time without anyone noticing. * * The last rule matters most. A bare `复制` with no hint and no code nearby is genuinely * ambiguous, and choosing silently is what produced the original defect. It is promoted to * the turn's copy button only when nothing else in that turn was classified as one, and * that promotion is visible in the returned `promoted` flag rather than hidden. */ export type CopyKind = "turn" | "canvas" | "code" | "other"; /* ------------------------------------------------------------------ */ /* The `via` tag: what the capture was, not just what it said */ /* ------------------------------------------------------------------ */ /** * `via` is the transport's statement about HOW the text was obtained, and the driver * reads it to attribute a failure correctly. That makes it protocol-adjacent metadata: * if the writer and the reader disagree about the vocabulary, the failure is silent and * lands on the wrong side. BUG-015 is exactly that -- the DOM path emits `dom-only` * when no copy source was clicked, the driver only recognised `dom-fallback`, and a * degraded capture was then judged as if it were authoritative text. * * So the vocabulary lives here, next to the classification it describes, as named values * both sides reference. A literal on either side alone is what drifted. */ export const VIA_DOM_FALLBACK = "dom-fallback"; export const VIA_DOM_ONLY = "dom-only"; export interface CopyViaFacts { /** The tag as reported. */ via: string; /** * The text was read out of the DOM instead of a copy affordance, so its completeness * cannot be proven. Both markers count: "we clicked a source and the clipboard failed" * and "we never had a source to click" are the same epistemic position. */ degraded: boolean; /** * The clicked affordance was a code block. Its payload is that block's body -- the * fence shape is the affordance's business, not the reply's, which is why the * one-block rule cannot be applied to it (see driver.gatePlan). */ codeSource: boolean; } /** The tag for a successful capture: `turn:/[/reply][+clipboard]`. */ export function copyViaTag(turnId: string | null, kind: CopyKind, isReply: boolean, clipboardRead = false): string { return `turn:${turnId ?? "?"}/${kind}${isReply ? "/reply" : ""}${clipboardRead ? "+clipboard" : ""}`; } /** * The canonical tag, anchored end to end: `turn:/[/reply][+clipboard]`. * * Anchoring is the anti-spoofing measure, not cosmetic: `turnId` is a DOM `data-testid`, and a * search for "code" anywhere in the string would let something shaped like * `turn:x/code/turn/reply` claim to be a code source and thereby waive the one-block rule. * The id is a single path segment here, so only the real `/` position can match. */ const COPY_VIA_TAG = /^turn:[^/\s]+\/(turn|canvas|code|other)(?:\/reply)?(?:\+[a-z-]+)?$/; /** * Read a `via` tag back into the two facts the gate needs. * * `degraded` matches the marker anywhere in the string because the tag carries a diagnostic * suffix (`dom-fallback (clipboard unavailable)`); `codeSource` comes from the kind segment of * a fully matched tag. Anything unrecognised yields `false` for both -- an unknown capture is * not granted an exemption, and "not degraded" is the status quo for the ordinary path. */ export function readCopyVia(copyVia: string | null | undefined): CopyViaFacts { const via = (copyVia ?? "").trim(); return { via, degraded: via.includes(VIA_DOM_FALLBACK) || via.includes(VIA_DOM_ONLY), codeSource: COPY_VIA_TAG.exec(via)?.[1] === "code", }; } export interface CopyButtonFacts { /** aria-label, title and data-testid joined -- whatever the element exposes. */ label: string; /** Whether a `
` sits in its ancestry, within the same few levels. */
	inCode: boolean;
	/**
	 * Whether the button sits in the turn's action bar. Structural fact (BUG-052): a canvas
	 * full of code blocks used to make the turn's own 复制 read as a code source. The page
	 * decides this from the action-bar container / bounded climb, never from `
`.
	 */
	inTurnActionBar?: boolean;
	/** Whether the button lives inside a writing-block (canvas) surface. */
	inWritingBlock?: boolean;
}

export interface ClassifiedCopyButton {
	kind: CopyKind;
	/** The turn's copy button copies the reply; a user turn's copies the message. */
	isReply: boolean;
	/** True for a code-block source, which is also what `nearCodeBlock` reports. */
	nearCodeBlock: boolean;
	/** Set when the ambiguity rule decided this one, so callers can say so. */
	promoted: boolean;
	/** Why, for diagnostics. Short and stable. */
	reason: string;
}

const CODE_HINT = /代码|code/i;
const REPLY_HINT = /回复|reply|response/i;
const MESSAGE_HINT = /消息|message/i;
const TURN_TESTID = /copy-turn-action-button/i;

/**
 * Turn a turn's buttons into copy sources.
 *
 * Returns one entry per input button, in the same order, so indices stay stable for
 * `--index` selection.
 */
export function classifyCopyButtons(buttons: readonly CopyButtonFacts[]): ClassifiedCopyButton[] {
	const classified = buttons.map((button): ClassifiedCopyButton => {
		const label = button.label;
		if (CODE_HINT.test(label)) {
			return { kind: "code", isReply: false, nearCodeBlock: true, promoted: false, reason: "label names a code block" };
		}
		// Structural identity before any label guessing (BUG-052): the turn bar's own button
		// is the turn's copy button even when the reply is full of code, and a canvas toolbar
		// button is its canvas's copy button even when its label is a bare 复制.
		if (button.inTurnActionBar) {
			return {
				kind: "turn",
				isReply: !MESSAGE_HINT.test(label),
				nearCodeBlock: false,
				promoted: false,
				reason: "sits in the turn action bar",
			};
		}
		const turnLike = TURN_TESTID.test(label) || REPLY_HINT.test(label) || MESSAGE_HINT.test(label);
		if (turnLike) {
			return {
				kind: "turn",
				isReply: !MESSAGE_HINT.test(label),
				nearCodeBlock: false,
				promoted: false,
				reason: TURN_TESTID.test(label) ? "testid" : "label names a reply",
			};
		}
		if (button.inWritingBlock) {
			// Inside a canvas: only a button in the code block itself is a code source; the
			// toolbar's 复制 button is the canvas's own copy source.
			return button.inCode
				? { kind: "code", isReply: false, nearCodeBlock: true, promoted: false, reason: "sits inside a canvas code block" }
				: { kind: "canvas", isReply: false, nearCodeBlock: false, promoted: false, reason: "canvas toolbar copy" };
		}
		if (button.inCode) {
			return { kind: "code", isReply: false, nearCodeBlock: true, promoted: false, reason: "sits inside a code block" };
		}
		return { kind: "other", isReply: false, nearCodeBlock: false, promoted: false, reason: "no hint" };
	});

	// Ambiguity rule: only when nothing was recognised as the turn's copy button -- and
	// never when a canvas source exists. For canvas replies the structural anchors are
	// authoritative, and a bare 复制 promoted by guesswork is exactly the wrong turn
	// (BUG-052: "扫到 1 个候选:code:复制" was this guess misfiring).
	const hasTurn = classified.some((entry) => entry.kind === "turn");
	const hasCanvas = classified.some((entry) => entry.kind === "canvas");
	if (!hasTurn && !hasCanvas) {
		const candidates = classified
			.map((entry, index) => ({ entry, index }))
			.filter(({ entry }) => entry.kind === "other");
		if (candidates.length === 1) {
			const only = candidates[0].entry;
			only.kind = "turn";
			only.isReply = true;
			only.promoted = true;
			only.reason = "promoted: the only unclassified copy button in this turn";
		}
	}

	return classified;
}

/* ------------------------------------------------------------------ */
/* Naming a copy miss: three different failures, three different names */
/* ------------------------------------------------------------------ */

/**
 * Why a copy attempt found nothing.
 *
 * The three cases are different investigations, so they get different names:
 *
 *   `turn-not-located`   the assistant turn itself was never found. That is a
 *                        selector / DOM-shape mismatch -- our model of the page is
 *                        wrong -- and nothing about the turn's buttons can be
 *                        concluded from it.
 *   `no-copy-control`    the turn was located and read, but it exposes no copy
 *                        affordance at all (the action bar mounts lazily, or this
 *                        turn genuinely has none).
 *   `no-source-selected` candidates exist, and the selection policy in force
 *                        (copyMode / copyLabel / index) matched none of them.
 *
 * BUG-058 filed the first case as the second: with no assistant node in the DOM the
 * message said "这一轮确实没有复制控件", which is a claim the code had not established --
 * it never saw the turn. BUG-045 is the same defect shape, a diagnostic naming a cause
 * it did not prove, and the cost is the same: the next investigation starts in the
 * wrong layer (here, hunting for a missing button instead of a stale selector).
 *
 * The wording lives here, as a pure function, for the same reason the classification
 * does: the injected page script is a string nothing type-checks, and this text is what
 * a user reads when the bridge cannot do its job.
 */
export type CopyMissReason = "turn-not-located" | "no-copy-control" | "no-source-selected";

export interface CopyMissFacts {
	/** The anchor the page reported. `none` means no assistant node was found at all. */
	anchor: string;
	/** Length of the reply text read off the located turn; 0 when nothing was located. */
	replyTextLength: number;
	/** How many copy candidates were found inside the located turn. */
	sourceCount: number;
	/** `kind:label` for each candidate, already joined for display. Diagnostics only. */
	sourceLabels: string;
	/** Whether the located reply renders as message text or as canvas surface(s). */
	surface?: string;
	canvasCount?: number;
}

/** Which of the three failures this is. `anchor: "none"` outranks the candidate count. */
export function classifyCopyMiss(facts: CopyMissFacts): CopyMissReason {
	if (facts.anchor === "none") return "turn-not-located";
	return facts.sourceCount === 0 ? "no-copy-control" : "no-source-selected";
}

/**
 * The user-facing sentence for a copy miss.
 *
 * The first line stays what it always said -- what was scanned and found -- and the
 * second line names the cause. Only the second line differs between the three cases;
 * keeping the shared part stable means a reader can still recognise the message.
 */
export function describeCopyMiss(facts: CopyMissFacts): string {
	const surfaceFact = facts.surface === "canvas" ? `surface=canvas canvasCount=${facts.canvasCount ?? 0}。` : "";
	const seen = facts.sourceLabels ? `:${facts.sourceLabels}` : "";
	const scanned = `这一轮里没有找到可用的复制源(${surfaceFact}扫到 ${facts.sourceCount} 个候选${seen})。`;
	const located = `助手回复已定位(anchor=${facts.anchor},正文 ${facts.replyTextLength} 字)`;

	switch (classifyCopyMiss(facts)) {
		case "turn-not-located":
			return (
				scanned +
				`原因不是"这一轮没有复制控件":页面上没有定位到任何助手回复(anchor=none),` +
				`也就是选择器与当前 DOM 形态不匹配。请跑 /brb doctor,并把页面 URL 一起报告。` +
				`也先跑 /brb sources 看当前页面到底提供了哪些按钮。`
			);
		case "no-copy-control":
			return (
				scanned +
				`${located},但这一轮里确实没有任何复制控件:若是刚加载或刚滚动到该轮,操作条可能还没挂载,稍后重试即可。` +
				`也先跑 /brb sources 看当前页面到底提供了哪些按钮。`
			);
		case "no-source-selected":
			return (
				scanned +
				`${located},也扫到了候选,但没有一个被当前策略选中:检查 copyMode / copyLabel` +
				`(例如 /brb config {"copyLabel":"^复制"})。` +
				`也先跑 /brb sources 看当前页面到底提供了哪些按钮。`
			);
	}
}

/* ------------------------------------------------------------------ */
/* The copy receipt: say which payload this is, never just how big      */
/* ------------------------------------------------------------------ */

/**
 * The one-line receipt for a copy.
 *
 * The old receipt said `已取到方案 N 字` for every source, which reads the same whether
 * the caller received the whole reply or a single block out of the middle of it.
 * Measured live (BUG-059): a 4194-character reply came back as a 2082-character code
 * block and the line did not mention it -- the caller had no way to know the answer had
 * been halved.
 *
 * The ruling froze the two forms below. `via` travels as the transport tag
 * (`turn:/...`), and `codeSource` comes from the anchored matcher above, so
 * the wording is derived from what was captured rather than from what was requested: a
 * degraded capture (DOM fallback) keeps its own tag on the 通道 line, and this line does
 * not claim the transport did something it did not.
 */
export function describeCopyReceipt(facts: {
	/** Characters actually handed back. */
	chars: number;
	/** Characters of the whole reply those came from. */
	replyTextLength: number;
	/** The transport tag of the capture (`via`), when there is one. */
	via?: string | null;
	/**
	 * The source was PROMOTED by the ambiguity rule: a bare 复制 with no structural proof
	 * that it belongs to this turn (BUG-063, STRUCTURAL_RISK).
	 *
	 * Such a button may be a code block's own copy button wearing no marker -- the 2026-10
	 * run-block renders exactly that way -- so the receipt must not claim the whole reply.
	 * Saying so is the whole guard: the payload is still handed over (refusing it would
	 * break the legitimate bare-copy turns of BUG-003), but it stops calling itself
	 * something nobody verified.
	 */
	promoted?: boolean;
}): string {
	if (readCopyVia(facts.via).codeSource) {
		return `已提取代码块 ${facts.chars} 字(整轮 ${facts.replyTextLength} 字,其余正文未包含)`;
	}
	if (facts.promoted) {
		return `已取回回复 ${facts.chars} 字(来源未经结构确认:该轮没有结构化的复制控件,这是唯一候选)`;
	}
	return `已取回完整回复 ${facts.chars} 字`;
}

/* ------------------------------------------------------------------ */
/* When may auto hand back a code block instead of the whole reply      */
/* ------------------------------------------------------------------ */

/** Share of the reply the block must cover before auto takes it alone (ruling: 90%). */
export const CODE_DOMINANCE_NUMERATOR = 9;
export const CODE_DOMINANCE_DENOMINATOR = 10;
/** Characters of prose the reply may still carry around that block (ruling: 200). */
export const CODE_DOMINANCE_PROSE_ALLOWANCE = 200;

/**
 * Does a lone code block *constitute* the reply, or is it just inside one?
 *
 * `copyMode: "auto"` used to take the code source whenever the turn happened to render
exactly one code block, on the assumption that one block means the plan is that block.
 * Measured live against that assumption (BUG-059): a 4194-character reply came back as its
 * 2082-character block, with a receipt that said only `已取到方案 2082 字` -- the caller had
 * no way to see half the answer was gone. The rule's own comment states the reason it
 * exists is to avoid silently truncating a plan.
 *
 * So the block has to earn it: it must cover at least 90% of the reply's characters AND
 * leave at most 200 characters of prose beside it. Both conditions are integer
 * comparisons -- a float ratio deciding whether a plan gets halved is a judgement no test
 * can pin down. An unmeasurable reply (`replyTextLength` 0) never qualifies: unknown is
 * not a licence to guess.
 *
 * This deliberately does not touch `copyMode: "code"`. Asking for the block is a
 * different statement from assuming it.
 */
export function codeSourceDominates(facts: { codeChars: number; replyTextLength: number }): boolean {
	const { codeChars, replyTextLength } = facts;
	if (replyTextLength <= 0) return false;
	if (codeChars * CODE_DOMINANCE_DENOMINATOR < replyTextLength * CODE_DOMINANCE_NUMERATOR) return false;
	return replyTextLength - codeChars <= CODE_DOMINANCE_PROSE_ALLOWANCE;
}