import type { Point, SnapshotNode } from '../../../kernel/snapshot.ts';
import type {
  AgentDeviceRuntime,
  CommandContext,
  CommandSessionRecord,
} from '../../../runtime-contract.ts';
import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts';
import { buildSnapshotDiff } from '../../../snapshot/snapshot-diff.ts';
import { displayLabel, formatRole } from '../../../snapshot/snapshot-lines.ts';
import { isAndroidInputMethodSnapshotNode } from '../../../snapshot/android-input-method-overlays.ts';
import { normalizeType } from '../../../utils/text-surface.ts';
import { summarizeAxEvidence } from '../../../utils/ax-digest.ts';
import type {
  InteractionEvidence,
  ResolvedInteractionTarget,
  SettleObservation,
  SettleParams,
  SettleTailEntry,
} from '../../../contracts/interaction.ts';
import type { CapturedSnapshot } from './selector-read-shared.ts';
import {
  DEFAULT_STABLE_QUIET_MS,
  DEFAULT_STABLE_TIMEOUT_MS,
  runStableCaptureLoop,
  TINY_STABLE_TREE_HINT,
  TINY_STABLE_TREE_NODE_COUNT,
} from './stable-capture.ts';

/**
 * `--settle` (#1101): after a mutating interaction, wait for the UI to go
 * quiet (wait stable's loop, shared via stable-capture.ts) and return the
 * settled DIFF against the pre-action tree in the same response — one round
 * trip instead of the interact → observe pair.
 *
 * Best-effort by contract: this module never throws. The action already
 * succeeded when it runs; observation quality is advisory (same principle as
 * `--verify` evidence).
 */

export type SettleOutcome = {
  observation: SettleObservation;
  /** Nodes of the final capture; doubles as the `--verify` evidence source. */
  settledNodes?: SnapshotNode[];
};

// Changed-lines bound: the settled diff is the response payload, and unbounded
// added/removed lists on a full screen transition would crowd out everything
// else. The summary always carries the true counts.
const MAX_SETTLE_DIFF_LINES = 80;

// Unchanged-interactive-tail bound: same token-budget principle as the diff
// line cap, sized smaller since the tail is a fallback list, not the primary
// payload.
const MAX_SETTLE_TAIL_ENTRIES = 20;

export const NEVER_SETTLED_HINT =
  'The UI kept changing for the whole settle budget (animation, carousel, or ticker?), so no settled diff is shown. Raise --timeout, wait for specific content, or take a fresh snapshot.';

const SETTLE_CAPTURE_STALLED_HINT =
  'A snapshot capture stalled past the settle budget, so no settled diff is shown. The action itself succeeded; observe with wait stable or snapshot.';

export async function settleAfterInteraction(
  runtime: AgentDeviceRuntime,
  options: CommandContext,
  params: SettleParams & { resolved: ResolvedInteractionTarget },
): Promise<SettleOutcome> {
  const quietMs = params.quietMs ?? DEFAULT_STABLE_QUIET_MS;
  const timeoutMs = params.timeoutMs ?? DEFAULT_STABLE_TIMEOUT_MS;
  const base: SettleObservation = { settled: false, waitedMs: 0, captures: 0, quietMs, timeoutMs };
  try {
    const outcome = await runStableCaptureLoop(runtime, options, {
      quietMs,
      timeoutMs,
      resetBudgetOnPrivateAxRecovery: true,
    });
    const observation: SettleObservation = {
      ...base,
      settled: outcome.settled,
      waitedMs: outcome.waitedMs,
      captures: outcome.captures,
    };
    if (!outcome.lastCapture) {
      return {
        observation: {
          ...observation,
          hint: outcome.stalled ? SETTLE_CAPTURE_STALLED_HINT : NEVER_SETTLED_HINT,
        },
      };
    }
    const { stored, session } = await storeSettledSnapshot(runtime, options, outcome.lastCapture);
    const settledNodes = outcome.lastCapture.snapshot.nodes;
    return {
      observation: {
        ...observation,
        // The diff (with its added-line refs) is only attached when the settled
        // tree actually became the stored session snapshot: those refs must be
        // valid against the tree the next @ref command resolves on. The daemon
        // treats `diff` presence as "this response issues refs". Unsettled
        // captures are intentionally diff-less: they are not a stable
        // observation, so surfacing refs would invite agents to act on
        // advisory state.
        ...(outcome.settled && stored
          ? buildSettleDiffAndTail(
              resolveBaselineNodes(params.resolved),
              settledNodes,
              params.resolved.point,
              session?.appBundleId,
            )
          : {}),
        ...resolveSettleHint(outcome, stored, settledNodes.length),
      },
      settledNodes,
    };
  } catch (error) {
    // Never fail the action over the observation: report that settling itself
    // broke and let the caller fall back to an explicit snapshot.
    return {
      observation: {
        ...base,
        hint: `Settle observation unavailable (${error instanceof Error ? error.message : String(error)}). The action itself succeeded; take a snapshot to observe the result.`,
      },
    };
  }
}

/**
 * `--settle --verify` composition: the settle loop's final capture doubles as
 * the verify evidence source, so the pair costs zero extra captures. Without a
 * final capture there is no evidence — best-effort, like verify itself.
 */
export function settleEvidence(
  settledNodes: SnapshotNode[] | undefined,
  preActionNodes: SnapshotNode[] | undefined,
): InteractionEvidence | undefined {
  if (!settledNodes) return undefined;
  const after = summarizeAxEvidence(settledNodes);
  const changedFromBefore =
    preActionNodes !== undefined && after.digest !== summarizeAxEvidence(preActionNodes).digest;
  return { ...after, changedFromBefore };
}

function resolveBaselineNodes(resolved: ResolvedInteractionTarget): SnapshotNode[] {
  return 'preActionNodes' in resolved && resolved.preActionNodes ? resolved.preActionNodes : [];
}

function buildSettleDiff(
  baselineNodes: SnapshotNode[],
  settledNodes: SnapshotNode[],
  appBundleId: string | undefined,
): NonNullable<SettleObservation['diff']> {
  // Flattened compare, like `diff -i`: both sides are interactive-flavored
  // captures and depth jitter across captures should not read as change. When
  // the baseline came from a richer stored tree (ref targets reuse the session
  // snapshot), extra baseline-only lines surface as removals — advisory noise,
  // the same baseline caveat --verify's changedFromBefore already accepts.
  const diff = buildSnapshotDiff(
    withoutSettleChrome(baselineNodes, appBundleId),
    withoutSettleChrome(settledNodes, appBundleId),
    { flatten: true, withRefs: true },
  );
  const changed = diff.lines.filter((line) => line.kind !== 'unchanged');
  const lines = capSettleDiffLines(changed).map((line) => ({
    kind: line.kind as 'added' | 'removed',
    text: line.text,
    ...(line.ref ? { ref: line.ref } : {}),
  }));
  return {
    summary: diff.summary,
    lines,
    ...(changed.length > lines.length ? { truncated: true as const } : {}),
  };
}

function buildSettleDiffAndTail(
  baselineNodes: SnapshotNode[],
  settledNodes: SnapshotNode[],
  actionPoint: Point | undefined,
  appBundleId: string | undefined,
): Pick<SettleObservation, 'diff' | 'tail' | 'tailTruncated'> {
  const diff = buildSettleDiff(baselineNodes, settledNodes, appBundleId);
  return { diff, ...buildSettleTail(diff, settledNodes, actionPoint, appBundleId) };
}

/**
 * Unchanged interactive refs tail: attached ONLY when the settled diff carries
 * zero MEANINGFUL added-line refs (a modal-dismiss/toast-only diff shows
 * removals but nothing added, so the next actionable target is otherwise
 * invisible). "Meaningful" excludes two ref classes that are provably not a
 * NEW target (live-verified on the July 2026 React Navigation benchmark flow):
 *
 * - Keyboard chrome refs: a fill that summons the keyboard adds the keyboard
 *   container line, but that is not a next target the caller asked for.
 * - Self-echo refs: added lines whose settled node's rect contains the action
 *   point are re-descriptions of the element the caller JUST acted on (a
 *   filled field re-labels itself with its new value, and its ancestor
 *   wrappers inherit that label), not something new to press next.
 *
 * Refs already present on the diff's added lines (meaningful or not) are also
 * excluded from the tail itself so it never repeats what the diff already
 * handed the caller.
 */
function buildSettleTail(
  diff: NonNullable<SettleObservation['diff']>,
  settledNodes: SnapshotNode[],
  actionPoint: Point | undefined,
  appBundleId: string | undefined,
): Pick<SettleObservation, 'tail' | 'tailTruncated'> {
  const addedRefs = new Set(
    diff.lines.filter((line) => line.kind === 'added' && line.ref).map((line) => line.ref),
  );
  const chromeRefs = collectSettleChromeRefs(settledNodes, appBundleId);
  const byRef = new Map(settledNodes.filter((node) => node.ref).map((node) => [node.ref, node]));
  const hasMeaningfulAddedRef = [...addedRefs].some(
    (ref) =>
      ref !== undefined && !chromeRefs.has(ref) && !isSelfEchoNode(byRef.get(ref), actionPoint),
  );
  if (hasMeaningfulAddedRef) return {};
  return buildSettleTailEntries(settledNodes, addedRefs, appBundleId);
}

function isSelfEchoNode(node: SnapshotNode | undefined, actionPoint: Point | undefined): boolean {
  if (!node?.rect || !actionPoint) return false;
  const { x, y, width, height } = node.rect;
  return (
    actionPoint.x >= x &&
    actionPoint.x <= x + width &&
    actionPoint.y >= y &&
    actionPoint.y <= y + height
  );
}

// Structural container roles that survive an interactive-only capture (as a
// lone root or alongside real content) but are never a next actionable
// target: application/window chrome, not a control. `snapshot -i` itself
// still lists these lines, but the tail exists specifically to name pressable
// targets, so it drops them rather than spend budget on chrome.
const STRUCTURAL_TAIL_ROLES = new Set(['application', 'window']);

/**
 * The filtering/cap step behind `buildSettleTail`, split out so the dedup
 * rule (excludeRefs) is unit-testable independent of the trigger condition
 * above.
 *
 * Inclusion bar matches what `snapshot -i` itself would show for the same
 * interactive-only capture: presence in `settledNodes` (already filtered to
 * interactive content upstream) IS the interactivity bar, so this does NOT
 * additionally require `hittable === true`. A real dismiss-animation capture
 * routinely reports transient buttons as `hittable: false`/`undefined` right
 * after the dismissing element leaves — requiring `hittable === true` here
 * was stricter than `snapshot -i`'s own bar and silently dropped exactly the
 * buttons the tail exists to surface (#1167 post-merge benchmark). Structural
 * application/window chrome, any keyboard container/chrome subtree (iOS), and
 * Android IME/persistent-system chrome (#1198) are excluded on top of that
 * bar: never a next actionable target either way.
 */
export function buildSettleTailEntries(
  settledNodes: SnapshotNode[],
  excludeRefs: ReadonlySet<string | undefined>,
  appBundleId?: string,
): Pick<SettleObservation, 'tail' | 'tailTruncated'> {
  const chromeRefs = collectSettleChromeRefs(settledNodes, appBundleId);
  const candidates = settledNodes.filter(
    (node) =>
      node.ref &&
      node.interactionBlocked !== 'covered' &&
      !excludeRefs.has(node.ref) &&
      !chromeRefs.has(node.ref) &&
      !STRUCTURAL_TAIL_ROLES.has(formatRole(node.type ?? 'Element')),
  );
  if (candidates.length === 0) return {};
  const tail: SettleTailEntry[] = candidates.slice(0, MAX_SETTLE_TAIL_ENTRIES).map((node) => {
    const role = formatRole(node.type ?? 'Element');
    const label = displayLabel(node, role);
    return { ref: node.ref, role, ...(label ? { label } : {}) };
  });
  return {
    tail,
    ...(candidates.length > tail.length ? { tailTruncated: true as const } : {}),
  };
}

// Editable text roles (normalized `node.type` vocabulary) used by the
// keyboard-window guard below.
const EDITABLE_TEXT_TYPES = new Set([
  'textfield',
  'securetextfield',
  'textview',
  'textarea',
  'searchfield',
  'edittext',
]);

/**
 * Keyboard chrome classification, live-verified against the iPhone 17 Pro
 * simulator (iOS 26, July 2026): the software keyboard renders in its OWN
 * dedicated window (UIRemoteKeyboardWindow). That window contains the
 * `[Keyboard]` container (keys plus real XCUIElementTypeButton chrome like
 * shift/Emoji/return) AND a SIBLING subtree holding the "Next keyboard" and
 * "Dictate" buttons — siblings of the container, so a container-descendant
 * walk alone provably misses them. The rule is therefore: every node inside
 * a window that has a `[Keyboard]` descendant is keyboard chrome.
 *
 * Detection stays structural (`parentIndex` chains), never label-based:
 * keyboard chrome text is locale-dependent (the verified capture had Polish
 * key labels) and a label list would silently stop matching under a
 * different input language.
 *
 * Conservative guard: a window that also hosts an editable text node OUTSIDE
 * the keyboard container is never window-classified — iOS hosts
 * inputAccessoryView content (e.g. a messaging composer) in the keyboard
 * window, and hiding the field the user is typing into would be worse than
 * leaking chrome. Such windows fall back to the container-descendant walk.
 */
type KeyboardChrome = {
  /** Chrome node indexes EXCLUDING the containers: stripped from the diff. */
  strippedIndexes: ReadonlySet<number>;
  /** Refs of ALL chrome incl. containers/window: trigger + tail exclusion. */
  refs: ReadonlySet<string>;
};

const EMPTY_KEYBOARD_CHROME: KeyboardChrome = { strippedIndexes: new Set(), refs: new Set() };

function collectKeyboardChrome(nodes: SnapshotNode[]): KeyboardChrome {
  const containerIndexes = new Set(
    nodes.filter((node) => normalizeType(node.type ?? '') === 'keyboard').map((node) => node.index),
  );
  if (containerIndexes.size === 0) return EMPTY_KEYBOARD_CHROME;
  const byIndex = new Map(nodes.map((node) => [node.index, node]));
  const chromeIndexes = collectSubtreeIndexes(nodes, byIndex, containerIndexes);
  const windowIndexes = resolveKeyboardWindowIndexes(nodes, byIndex, chromeIndexes);
  for (const index of collectSubtreeIndexes(nodes, byIndex, windowIndexes)) {
    chromeIndexes.add(index);
  }
  const refs = new Set(
    nodes.filter((node) => node.ref && chromeIndexes.has(node.index)).map((node) => node.ref),
  );
  // The [Keyboard] container line itself survives the diff so "keyboard
  // appeared/left" stays one visible signal line; everything else collapses.
  const strippedIndexes = new Set(
    [...chromeIndexes].filter((index) => !containerIndexes.has(index)),
  );
  return { strippedIndexes, refs };
}

/** The root indexes plus every node whose parent chain passes through one. */
function collectSubtreeIndexes(
  nodes: SnapshotNode[],
  byIndex: Map<number, SnapshotNode>,
  rootIndexes: ReadonlySet<number>,
): Set<number> {
  const indexes = new Set(rootIndexes);
  if (rootIndexes.size === 0) return indexes;
  for (const node of nodes) {
    if (hasAncestorIn(node, byIndex, rootIndexes)) indexes.add(node.index);
  }
  return indexes;
}

// SystemUI hosts BOTH persistent chrome and actionable overlays (volume
// panel, media/output pickers), so chrome is never a package-level fact.
// Within `com.android.systemui`, only window-runs carrying a status-bar or
// navigation-bar marker resource-id drop; every other systemui surface is
// kept. Marker set live-verified on the emulator: the status-bar window
// carries `status_bar*` ids throughout while the VolumeDialog window carries
// only `volume_dialog*` ids (`input_method_nav*` bars are IME-owned and
// handled by the IME tier).
const ANDROID_SYSTEM_CHROME_PACKAGE = 'com.android.systemui';
const ANDROID_SYSTEM_CHROME_MARKER_PREFIXES = [
  'com.android.systemui:id/status_bar',
  'com.android.systemui:id/navigation_bar',
];

function hasAndroidSystemChromeMarker(node: SnapshotNode): boolean {
  const identifier = node.identifier ?? '';
  return ANDROID_SYSTEM_CHROME_MARKER_PREFIXES.some((prefix) => identifier.startsWith(prefix));
}

/**
 * Android settle chrome (#1198): IME-owned nodes collapse to one surviving
 * line per contiguous run; systemui status/nav-bar window-runs drop from both
 * diff sides; every other foreign node — system dialogs (package `android`),
 * permission prompts, AND actionable systemui overlays like the volume panel
 * — is kept in full. Constraint: package membership is strictly per-node by
 * the node's own `bundleId` — parentIndex chains can cross windows on Android
 * (enforced by the settle.test.ts cross-window regression test); run grouping
 * only ever walks parent chains BETWEEN same-package nodes, so it cannot
 * swallow another package's node. Inert on iOS/macOS: those nodes never set
 * `bundleId`.
 */
function collectAndroidSettleChrome(
  nodes: SnapshotNode[],
  appBundleId: string | undefined,
): KeyboardChrome {
  const byIndex = new Map(nodes.map((node) => [node.index, node]));
  const imeIndexes = new Set(
    nodes.filter((node) => isAndroidInputMethodSnapshotNode(node)).map((node) => node.index),
  );
  const imeContainerIndexes = new Set(
    [...imeIndexes].filter((index) => {
      const parentIndex = byIndex.get(index)?.parentIndex;
      const parent = parentIndex !== undefined ? byIndex.get(parentIndex) : undefined;
      return !parent || !imeIndexes.has(parent.index);
    }),
  );
  // appBundleId is the session's pre-action value (not refreshed inside the
  // settle loop); it is only a never-drop-the-app-under-test guard here, so
  // staleness cannot hide a foreign dialog.
  const systemChromeIndexes =
    appBundleId === ANDROID_SYSTEM_CHROME_PACKAGE
      ? new Set<number>()
      : collectAndroidSystemChromeRunIndexes(nodes, byIndex, imeIndexes);
  // The one surviving container line per IME run; the rest of the run and all
  // status/nav-bar chrome never spend diff/tail budget.
  const strippedIndexes = new Set(
    [...imeIndexes].filter((index) => !imeContainerIndexes.has(index)),
  );
  for (const index of systemChromeIndexes) strippedIndexes.add(index);
  const refs = new Set(
    nodes
      .filter(
        (node) => node.ref && (imeIndexes.has(node.index) || systemChromeIndexes.has(node.index)),
      )
      .map((node) => node.ref),
  );
  if (strippedIndexes.size === 0 && refs.size === 0) return EMPTY_KEYBOARD_CHROME;
  return { strippedIndexes, refs };
}

/**
 * Systemui window-runs (contiguous same-package parent chains) that contain a
 * status/nav-bar marker anywhere in the run. The whole marked run drops —
 * unmarked wrappers above `status_bar_container` churn with the bar itself —
 * while unmarked runs (volume panel, media pickers) are kept whole.
 */
function collectAndroidSystemChromeRunIndexes(
  nodes: SnapshotNode[],
  byIndex: Map<number, SnapshotNode>,
  imeIndexes: ReadonlySet<number>,
): Set<number> {
  const systemUiIndexes = new Set(
    nodes
      .filter(
        (node) => node.bundleId === ANDROID_SYSTEM_CHROME_PACKAGE && !imeIndexes.has(node.index),
      )
      .map((node) => node.index),
  );
  if (systemUiIndexes.size === 0) return new Set();
  // Union-find-lite: each systemui node resolves to its run root (the nearest
  // ancestor chain member whose parent is absent or not systemui).
  const runRootByIndex = new Map<number, number>();
  const resolveRunRoot = (index: number): number => {
    const cached = runRootByIndex.get(index);
    if (cached !== undefined) return cached;
    const parentIndex = byIndex.get(index)?.parentIndex;
    const root =
      parentIndex !== undefined && systemUiIndexes.has(parentIndex)
        ? resolveRunRoot(parentIndex)
        : index;
    runRootByIndex.set(index, root);
    return root;
  };
  const markedRunRoots = new Set(
    [...systemUiIndexes]
      .filter((index) => {
        const node = byIndex.get(index);
        return node !== undefined && hasAndroidSystemChromeMarker(node);
      })
      .map((index) => resolveRunRoot(index)),
  );
  return new Set([...systemUiIndexes].filter((index) => markedRunRoots.has(resolveRunRoot(index))));
}

/** iOS keyboard-window chrome unioned with Android IME/system chrome. */
function collectSettleChrome(
  nodes: SnapshotNode[],
  appBundleId: string | undefined,
): KeyboardChrome {
  const keyboard = collectKeyboardChrome(nodes);
  const android = collectAndroidSettleChrome(nodes, appBundleId);
  if (keyboard.strippedIndexes.size === 0 && keyboard.refs.size === 0) return android;
  if (android.strippedIndexes.size === 0 && android.refs.size === 0) return keyboard;
  return {
    strippedIndexes: new Set([...keyboard.strippedIndexes, ...android.strippedIndexes]),
    refs: new Set([...keyboard.refs, ...android.refs]),
  };
}

function withoutSettleChrome(
  nodes: SnapshotNode[],
  appBundleId: string | undefined,
): SnapshotNode[] {
  const { strippedIndexes } = collectSettleChrome(nodes, appBundleId);
  if (strippedIndexes.size === 0) return nodes;
  return nodes.filter((node) => !strippedIndexes.has(node.index));
}

function collectSettleChromeRefs(
  nodes: SnapshotNode[],
  appBundleId: string | undefined,
): ReadonlySet<string> {
  return collectSettleChrome(nodes, appBundleId).refs;
}

/**
 * Windows eligible for whole-window chrome classification: nearest `[window]`
 * ancestor of each `[Keyboard]` container, minus windows hosting editable
 * text outside the container subtree (the inputAccessoryView guard above).
 */
function resolveKeyboardWindowIndexes(
  nodes: SnapshotNode[],
  byIndex: Map<number, SnapshotNode>,
  containerChromeIndexes: ReadonlySet<number>,
): Set<number> {
  const windowIndexes = new Set<number>();
  for (const index of containerChromeIndexes) {
    const node = byIndex.get(index);
    if (!node || normalizeType(node.type ?? '') !== 'keyboard') continue;
    const windowAncestor = findNearestWindowAncestor(node, byIndex);
    if (windowAncestor !== undefined) windowIndexes.add(windowAncestor);
  }
  for (const windowIndex of windowIndexes) {
    const windowSet = new Set([windowIndex]);
    const hostsEditableText = nodes.some(
      (node) =>
        EDITABLE_TEXT_TYPES.has(normalizeType(node.type ?? '')) &&
        !containerChromeIndexes.has(node.index) &&
        hasAncestorIn(node, byIndex, windowSet),
    );
    if (hostsEditableText) windowIndexes.delete(windowIndex);
  }
  return windowIndexes;
}

function findNearestWindowAncestor(
  node: SnapshotNode,
  byIndex: Map<number, SnapshotNode>,
): number | undefined {
  let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined;
  while (current) {
    if (normalizeType(current.type ?? '') === 'window') return current.index;
    current =
      typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
  }
  return undefined;
}

function hasAncestorIn(
  node: SnapshotNode,
  byIndex: Map<number, SnapshotNode>,
  ancestorIndexes: ReadonlySet<number>,
): boolean {
  let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined;
  while (current) {
    if (ancestorIndexes.has(current.index)) return true;
    current =
      typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
  }
  return false;
}

/**
 * Truncation policy: added lines win. They carry the settled tree's fresh
 * refs — the actionable half of the diff — while removals only describe what
 * left the screen (the summary still counts them). Relative order within each
 * kind is preserved; removals fill whatever budget the additions leave.
 */
function capSettleDiffLines<T extends { kind: string }>(changed: T[]): T[] {
  if (changed.length <= MAX_SETTLE_DIFF_LINES) return changed;
  const added = changed.filter((line) => line.kind === 'added');
  const keptAdded = new Set(added.slice(0, MAX_SETTLE_DIFF_LINES));
  let removedBudget = MAX_SETTLE_DIFF_LINES - keptAdded.size;
  const kept: T[] = [];
  for (const line of changed) {
    if (keptAdded.has(line)) {
      kept.push(line);
    } else if (line.kind === 'removed' && removedBudget > 0) {
      kept.push(line);
      removedBudget -= 1;
    }
  }
  return kept;
}

function resolveSettleHint(
  outcome: { settled: boolean; stalled: boolean },
  stored: boolean,
  settledNodeCount: number,
): { hint?: string } {
  if (outcome.stalled) return { hint: SETTLE_CAPTURE_STALLED_HINT };
  if (!outcome.settled) return { hint: NEVER_SETTLED_HINT };
  if (!stored) {
    return {
      hint: 'Settled on a sparse, unreadable tree — the diff is omitted. Use screenshot as visual truth before interacting further.',
    };
  }
  // Same weak-readiness signal wait stable reports: a settled-but-tiny tree
  // usually means a splash/loading surface, not real content.
  if (settledNodeCount < TINY_STABLE_TREE_NODE_COUNT) return { hint: TINY_STABLE_TREE_HINT };
  return {};
}

// The settle loop itself captures with updateSession: false (a capture that
// later stalls must not race a session write past the response). The FINAL
// capture is stored so follow-up snapshots/selectors see the latest surface.
// Only settled captures issue a diff/ref payload; unsettled stored captures
// conservatively mark prior refs stale through the runtime session writer.
// Sparse-quality captures are not stored (mirroring captureSelectorSnapshot)
// and therefore issue no refs. The fetched session is returned alongside
// `stored` so the caller can read `appBundleId` for settle-chrome scoping
// (#1198) without a second `sessions.get` round trip.
async function storeSettledSnapshot(
  runtime: AgentDeviceRuntime,
  options: CommandContext,
  capture: CapturedSnapshot,
): Promise<{ stored: boolean; session?: CommandSessionRecord }> {
  if (isSparseSnapshotQualityVerdict(capture.snapshot.snapshotQuality)) return { stored: false };
  const session = await runtime.sessions.get(options.session ?? 'default');
  if (!session) return { stored: false };
  await runtime.sessions.set({ ...session, snapshot: capture.snapshot });
  return { stored: true, session };
}
