/**
 * think-guards.ts — Redirect guards extracted from think.ts.
 *
 * Each guard is a near-pure function that inspects state + input and either:
 *   - Returns a new `KernelState` redirecting the loop (e.g. back to "thinking"
 *     with a user-message nudge), or
 *   - Returns `undefined` to pass through to the next guard / continue normal flow.
 *
 * Extracted verbatim from think.ts as Phase 2 of the kernel architecture rescue.
 * Pure extraction — behavior and side effects are identical to the original
 * inline blocks. `guardRequiredToolsBlock` still emits a `hooks.onThought` log
 * via `Effect.runSync` to match the yield* behavior in the original code.
 *
 * NOTE: These guards currently inject USER messages into `state.messages`.
 * Task 9 (a later refactor) will convert these to `pendingGuidance` writes
 * so the guidance section renders them inline rather than as raw user turns.
 */
import { Effect } from "effect";
import type { ProviderAdapter } from "@reactive-agents/llm-provider";
import {
  detectCompletionGaps,
  type ToolCallSpec,
} from "@reactive-agents/tools";
import { computeNoveltyRatio } from "../attend/tool-formatting.js";
import {
  buildEvidenceCorpusFromSteps,
  validateNumericGrounding,
} from "../verify/evidence-grounding.js";
import { gateNativeToolCallsForRequiredTools } from "../decide/tool-gating.js";
import {
  buildSuccessfulToolCallCounts,
  getMissingRequiredToolsFromSteps,
} from "../verify/requirement-state.js";
import { makeStep } from "../sense/step-utils.js";
import { makeObservationResult } from "../../utils/observation-helpers.js";
import { hasSuccessfulSubstantiveToolCall } from "../../loop/runner-helpers/grounded-terminal.js";
import type { ContextProfile } from "../../../context/context-profile.js";
import {
  transitionState,
  type KernelState,
  type KernelInput,
  type KernelHooks,
} from "../../../kernel/state/kernel-state.js";
import type { ReasoningStep } from "../../../types/index.js";

// ─────────────────────────────────────────────────────────────────────────────
// 1. guardRequiredToolsBlock
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fires when strict-dependency mode blocks a tool batch that does not include
 * a missing required tool. Emits a `[GATE]` log via `hooks.onThought` and
 * either:
 *   - Fails the run when `requiredToolQuantities` conflicts with `maxCallsPerTool`
 *     (no way to satisfy required minimums within the per-tool budget), or
 *   - Returns `status: "thinking"` with a redirect USER message and records the
 *     blocked tools in `meta.gateBlockedTools` so future batches skip re-gating.
 *
 * Re-invokes `gateNativeToolCallsForRequiredTools` internally so think.ts
 * doesn't need to pass the `blockedOptionalBatch` / `quotaBudgetConflict`
 * details as arguments.
 */
export function guardRequiredToolsBlock(
  rawCalls: readonly ToolCallSpec[],
  input: KernelInput,
  state: KernelState,
  _profile: ContextProfile,
  hooks: KernelHooks,
  newSteps: readonly ReasoningStep[],
  newTokens: number,
  newCost: number,
  thought: string,
  thinking: string | null,
): KernelState | undefined {
  const toolCallCounts = buildSuccessfulToolCallCounts(state.steps);

  const { blockedOptionalBatch, quotaBudgetConflict } =
    gateNativeToolCallsForRequiredTools(
      rawCalls,
      input.requiredTools ?? [],
      state.toolsUsed,
      input.relevantTools,
      toolCallCounts,
      input.maxCallsPerTool,
      input.requiredToolQuantities,
      input.strictToolDependencyChain,
      input.nextMovesPlanning,
    );

  if (!blockedOptionalBatch) return undefined;

  if ((quotaBudgetConflict?.length ?? 0) > 0) {
    const conflictLines = quotaBudgetConflict!.map(
      (entry) =>
        `${entry.toolName}: required minCalls=${entry.requiredMinCalls}, maxCallsPerTool=${entry.maxCalls}, actualCalls=${entry.actualCalls}`,
    );
    const conflictMsg =
      "Configuration conflict — required tool quotas cannot be satisfied within maxCallsPerTool budget:\n" +
      conflictLines.map((line) => `• ${line}`).join("\n") +
      "\nFix either requiredToolQuantities or maxCallsPerTool before retrying this run.";
    const conflictStep = makeStep("observation", conflictMsg, {
      observationResult: makeObservationResult("system", false, conflictMsg),
    });
    return transitionState(state, {
      steps: [...newSteps, conflictStep],
      tokens: newTokens,
      cost: newCost,
      status: "failed",
      error: conflictMsg,
      iteration: state.iteration + 1,
      meta: {
        ...state.meta,
        lastThought: thought,
        lastThinking: thinking,
      },
    });
  }

  const missing = getMissingRequiredToolsFromSteps(
    state.steps,
    input.requiredTools ?? [],
    input.requiredToolQuantities,
  );
  const nextRequired = missing[0] ?? "the missing required tool";
  const attemptedTools = rawCalls.map((tc) => tc.name);
  const writeHint =
    nextRequired.includes("write") || nextRequired.includes("file")
      ? ` Use the ${nextRequired} tool with a path from the task and the full report body as content (markdown).`
      : "";
  const blockMsg =
    `Required tools not yet satisfied: ${missing.join(", ")}. Strict dependency mode blocked this tool batch because it did not include any missing required tool. Call ${nextRequired} now with concrete arguments.${writeHint}`;

  // Emit [GATE] log via runSync — original used `yield* hooks.onThought(...)` inside an Effect.gen.
  // onThought returns Effect<void, never>, so runSync is safe here (no failure path).
  Effect.runSync(
    hooks.onThought(
      state,
      `[GATE] Model tried: ${attemptedTools.join(", ")} — blocked, need: ${missing.join(", ")}`,
    ),
  );

  const blockStep = makeStep("observation", blockMsg, {
    observationResult: makeObservationResult("system", false, blockMsg),
  });

  const prevBlocked =
    (state.meta.gateBlockedTools as readonly string[] | undefined) ?? [];
  const newBlocked = [...new Set([...prevBlocked, ...attemptedTools])];

  return transitionState(state, {
    steps: [...newSteps, blockStep],
    pendingGuidance: { requiredToolsPending: missing },
    tokens: newTokens,
    cost: newCost,
    status: "thinking",
    iteration: state.iteration + 1,
    meta: {
      ...state.meta,
      lastThought: thought,
      lastThinking: thinking,
      gateBlockedTools: newBlocked,
    },
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// 2. guardPrematureFinalAnswer
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fires when the resolver returns `final_answer` but required tools are still
 * missing and iteration budget is not exhausted. Injects a USER redirect
 * message (adapter-specific via `continuationHint` when available, generic
 * fallback otherwise) and loops back for another iteration.
 *
 * Returns `undefined` when no required tools are missing or budget is exhausted
 * (allowing the final answer through).
 */
export function guardPrematureFinalAnswer(
  input: KernelInput,
  state: KernelState,
  _profile: ContextProfile,
  adapter: ProviderAdapter,
  newSteps: readonly ReasoningStep[],
  newTokens: number,
  newCost: number,
): KernelState | undefined {
  const requiredTools = input.requiredTools ?? [];
  const missingRequired = getMissingRequiredToolsFromSteps(
    state.steps,
    requiredTools,
    input.requiredToolQuantities,
  );
  if (missingRequired.length === 0) return undefined;
  if (state.iteration >= ((state.meta.maxIterations as number) ?? 10) - 1) {
    return undefined;
  }
  // F1 — grounded-terminal invariant (2026-07-02): when required tools are
  // declared, at least one missing required tool is ACTUALLY CALLABLE (present
  // in the available schemas — the gate's "call X now" redirect must be
  // actionable), and ZERO substantive tool calls have succeeded, this guard
  // DEFERS to the Arbitrator's grounded-terminal gate (one bounded recovery
  // redirect, then forced abstention) instead of re-injecting the generic
  // "not done yet" redirect every iteration until the budget dies.
  // Partially-grounded runs (some success, specific required tool still
  // missing) and runs whose missing tools aren't wired at all keep the
  // existing per-attempt redirect below unchanged.
  const availableForDefer = new Set(
    (input.availableToolSchemas ?? []).map((t) => t.name),
  );
  if (
    !hasSuccessfulSubstantiveToolCall(state.steps) &&
    missingRequired.some((t) => availableForDefer.has(t))
  ) {
    return undefined;
  }

  // Use adapter hint for targeted guidance, fall back to generic redirect
  const lastActStep = state.steps.filter((s) => s.type === "action").pop();
  const lastTool = (lastActStep?.metadata?.toolCall as { name?: string } | undefined)?.name;
  const adapterRedirect = adapter.continuationHint?.({
    toolsUsed: state.toolsUsed,
    requiredTools: requiredTools as string[],
    missingTools: missingRequired,
    iteration: state.iteration,
    maxIterations: (state.meta.maxIterations as number) ?? 10,
    lastToolName: lastTool,
  });
  const redirectMsg =
    adapterRedirect ??
    `Not done yet — you still need to call: ${missingRequired.join(", ")}. Do not give a final answer until all required tools have been used.`;

  const redirectStep = makeStep("observation", redirectMsg, {
    observationResult: makeObservationResult("system", false, redirectMsg),
  });

  // Steps[] keeps the observability record; the LLM sees this redirect via
  // pendingGuidance → the Guidance: section on the next turn, not as a USER turn.
  return transitionState(state, {
    steps: [...newSteps, redirectStep],
    pendingGuidance: { requiredToolsPending: missingRequired },
    tokens: newTokens,
    cost: newCost,
    iteration: state.iteration + 1,
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. guardCompletionGaps
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fires when `detectCompletionGaps` identifies tools/actions mentioned in the
 * task description that were never invoked. Injects a USER message listing
 * the gaps and loops back.
 *
 * Returns `undefined` when no gaps detected or iteration budget is exhausted.
 */
export function guardCompletionGaps(
  input: KernelInput,
  state: KernelState,
  newSteps: readonly ReasoningStep[],
  newTokens: number,
  newCost: number,
): KernelState | undefined {
  const gaps = detectCompletionGaps(
    input.task,
    state.toolsUsed,
    input.allToolSchemas ?? input.availableToolSchemas ?? [],
    newSteps as ReasoningStep[],
  );
  if (gaps.length === 0) return undefined;
  if (state.iteration >= ((state.meta.maxIterations as number) ?? 10) - 1) {
    return undefined;
  }
  // F1 — grounded-terminal invariant (2026-07-02): same defer as
  // guardPrematureFinalAnswer/guardQualityCheck. RequiredTools declared + ZERO
  // substantive successes → the terminal attempt must reach the Arbitrator's
  // grounded-terminal gate (one bounded redirect, then forced abstention)
  // instead of an unbounded per-attempt "Not done yet" redirect here.
  // Partially-grounded runs keep the existing gap redirect unchanged.
  if (
    (input.requiredTools ?? []).length > 0 &&
    !hasSuccessfulSubstantiveToolCall(state.steps)
  ) {
    return undefined;
  }

  const gapMsg = `Not done yet — missing steps:\n${gaps.map((g) => `• ${g}`).join("\n")}`;
  const gapStep = makeStep("observation", gapMsg, {
    observationResult: makeObservationResult("system", false, gapMsg),
  });
  return transitionState(state, {
    steps: [...newSteps, gapStep],
    pendingGuidance: { oracleGuidance: gapMsg },
    tokens: newTokens,
    cost: newCost,
    iteration: state.iteration + 1,
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// 4. guardQualityCheck
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fires on the final-answer path (iteration > 0) when the adapter defines
 * `qualityCheck` and it returns a non-empty message. Injects the QC hint as
 * a USER message and sets `meta.qualityCheckDone = true` so it never fires twice
 * in the same run.
 *
 * Returns `undefined` on iteration 0, when QC already ran, when the adapter
 * has no `qualityCheck`, or when the check returns nothing.
 */
export function guardQualityCheck(
  input: KernelInput,
  state: KernelState,
  profile: ContextProfile,
  adapter: ProviderAdapter,
  newSteps: readonly ReasoningStep[],
  newTokens: number,
  newCost: number,
): KernelState | undefined {
  if (state.iteration <= 0) return undefined;
  if (state.meta.qualityCheckDone) return undefined;
  // F1 — grounded-terminal invariant (2026-07-02): when required tools are
  // declared and ZERO substantive tool calls have succeeded, the terminal
  // attempt must reach the Arbitrator's grounded-terminal gate (one bounded
  // recovery redirect, then forced abstention). A quality-check redirect here
  // would both delay that gate AND inject a `system`/success:true pseudo-
  // observation. Grounded runs and pure-synthesis tasks (no requiredTools)
  // keep the existing quality check unchanged.
  if (
    (input.requiredTools ?? []).length > 0 &&
    !hasSuccessfulSubstantiveToolCall(state.steps)
  ) {
    return undefined;
  }

  const qcMsg = adapter.qualityCheck?.({
    task: input.task,
    requiredTools: input.requiredTools ?? [],
    toolsUsed: state.toolsUsed,
    tier: profile.tier ?? "mid",
  });
  if (!qcMsg) return undefined;

  const qcStep = makeStep("observation", qcMsg, {
    observationResult: makeObservationResult("system", true, qcMsg),
  });
  return transitionState(state, {
    steps: [...newSteps, qcStep],
    pendingGuidance: { qualityGateHint: qcMsg },
    tokens: newTokens,
    cost: newCost,
    iteration: state.iteration + 1,
    // Prevent quality check from firing again next iteration
    meta: { ...state.meta, qualityCheckDone: true },
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// 5. guardDiminishingReturns
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Computes a diminishing-returns nudge message for the thinking path. Unlike
 * the other guards, this one does NOT own the full state transition — it
 * returns a replacement "thinking" state that includes:
 *   - The nudge message injected into `state.messages` as a user turn, and
 *   - An observation step containing the nudge, and
 *   - Iteration + token bookkeeping identical to the original inline branch.
 *
 * The caller (think.ts) passes in the `thinkingContent` and `thinkingSteps`
 * it has already assembled; this guard only fires when required tools are
 * missing, at least 3 real tool observations exist, and the last observation
 * adds <20% novelty vs prior observations.
 *
 * Returns `undefined` when any precondition fails — the caller then uses its
 * existing default nudge path.
 */
export function guardDiminishingReturns(
  state: KernelState,
  input: KernelInput,
  _profile: ContextProfile,
  newTokens: number,
  newCost: number,
  opts: {
    readonly thinkingContent: string;
    readonly thinkingSteps: readonly ReasoningStep[];
    readonly missingReq: readonly string[];
    readonly adapterOrDefaultNudge: string;
  },
): KernelState | undefined {
  const { thinkingContent, thinkingSteps, missingReq, adapterOrDefaultNudge } = opts;
  if (missingReq.length === 0) return undefined;

  // Layer 1: Novelty signal — strengthen nudge when recent observations add little new info.
  // If the model has gathered ≥3 real tool observations and the last one is <20% novel,
  // it has enough context. Override the nudge to be explicit about stopping research.
  const realObs = state.steps.filter(
    (s) =>
      s.type === "observation" &&
      (s.metadata?.observationResult as { toolName?: string } | undefined)?.toolName !== "system",
  );
  if (realObs.length < 3) return undefined;

  const lastObsText = realObs[realObs.length - 1].content;
  const priorObsText = realObs.slice(0, -1).map((s) => s.content).join(" ");
  // Safeguard: skip novelty check if either observation is missing or empty
  if (!lastObsText || !priorObsText) return undefined;

  const novelty = computeNoveltyRatio(lastObsText, priorObsText);
  if (novelty >= 0.20) return undefined;

  const pct = Math.round(novelty * 100);
  const nudgeMessage =
    `Research context is sufficient (last search: ${pct}% new information — diminishing returns). ` +
    `Do NOT search again. Call ${missingReq[0]} now to produce the output.`;

  const finalSteps: ReasoningStep[] = [
    ...thinkingSteps,
    makeStep("observation", nudgeMessage, {
      observationResult: makeObservationResult("system", true, nudgeMessage),
    }),
  ];

  // Keep adapterOrDefaultNudge referenced so the caller's fallback path is
  // documented — but the novelty override replaces it when this guard fires.
  void adapterOrDefaultNudge;

  return transitionState(state, {
    steps: finalSteps,
    pendingGuidance: { oracleGuidance: nudgeMessage },
    tokens: newTokens,
    cost: newCost,
    iteration: state.iteration + 1,
    priorThought: thinkingContent || state.priorThought,
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// 6. guardEvidenceGrounding
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fires on the final-answer path when the model's output contains dollar amounts
 * that are not present in the tool observation corpus (i.e. potential hallucinations).
 *
 * Evidence corpus is built from `extractedFact` fields on step metadata first
 * (dense, signal-rich values set by extractFactDeterministic in act.ts), then
 * falls back to raw observation content when extracted facts are sparse.
 *
 * Guards:
 *  - Skips when iteration === 0 (no tool results yet)
 *  - Skips when `meta.evidenceGroundingDone` is set (fires at most once per run)
 *  - Skips when the evidence corpus is too thin (< 20 chars)
 *  - Skips when no dollar amounts appear in the output
 *
 * Returns `undefined` when all checks pass — the caller then assembles the final output.
 */
export function guardEvidenceGrounding(
  state: KernelState,
  thought: string,
  newSteps: readonly ReasoningStep[],
  newTokens: number,
  newCost: number,
  grounding: import("../../state/kernel-state.js").GroundingConfig | undefined,
): KernelState | undefined {
  // Opt-in: the mid-loop "fix before finishing" nudge runs ONLY in block mode.
  // warn mode is advisory (must not interrupt); off = never runs.
  if (!grounding || grounding.mode !== "block") return undefined;
  // Only run once and only when there are prior iterations with real observations.
  if (state.iteration <= 0) return undefined;
  if (state.meta.evidenceGroundingDone) return undefined;

  // Evidence corpus must be AUTHORITATIVE — never lossy. Raw observation
  // bodies are the ground truth; extractedFact summaries are signal-rich
  // augmentation. Using extracted facts ALONE produced false-positive
  // rejections when the model's answer cited values present in raw obs
  // bodies (e.g., a GitHub commit body containing "$77,505") but absent
  // from the per-step extractedFact one-liner. The valid answer was then
  // re-prompted, the loop stalled, and the harness shipped raw tool JSON
  // — the upstream defect behind output poisoning.
  const extractedFacts = newSteps
    .filter((s) => s.type === "observation")
    .map((s) => (s.metadata?.extractedFact as string | undefined) ?? "")
    .filter(Boolean)
    .join("\n");
  const rawObservations = buildEvidenceCorpusFromSteps(newSteps, state.scratchpad);
  const evidenceCorpus = extractedFacts.length > 0
    ? `${rawObservations}\n\n${extractedFacts}`
    : rawObservations;

  const tolerance = grounding.tolerance ?? 0.01;
  const check = validateNumericGrounding(thought, evidenceCorpus, tolerance);
  if (check.ok) return undefined;

  const violationsMsg =
    `Output contains figures not found in tool observations:\n` +
    check.violations.map((v) => `• ${v}`).join("\n") +
    `\nRevise your answer to use only figures from the tool results.`;

  const gapStep = makeStep("observation", violationsMsg, {
    observationResult: makeObservationResult("system", false, violationsMsg),
  });

  return transitionState(state, {
    steps: [...newSteps, gapStep],
    pendingGuidance: { evidenceGap: violationsMsg },
    tokens: newTokens,
    cost: newCost,
    iteration: state.iteration + 1,
    meta: { ...state.meta, evidenceGroundingDone: true },
  });
}

// ── Recall overflow gate (Inc 1 — tier-aware context architecture) ───────────
//
// recall is only ever USABLE when a >4000-char tool result was truncated in the
// conversation thread and its storedKey was surfaced to the model. Below the
// inline cap (TOOL_RESULT_INLINE_CAP = 4000) the full data is already inline and
// NO key is surfaced — so advertising recall there lures weak models into BLIND
// recall calls with invented keys (trace 01KSV58K: recall("hn_posts") on a
// 3928-char inline result → {"found":false}). This gate hides recall from the
// model-visible tool set unless a usable key is present in the CURRENT window.
//
// The gate is scoped to the WINDOW (not the all-time scratchpad): message-window
// compaction leaves no recall pointer, so a key that scrolls out of the window is
// unrecallable — surfacing recall after it is gone would reintroduce blind recall.

/**
 * Matches ANY harness-surfaced recall key pointer inside a tool_result. The
 * harness writes several truncation/overflow markers depending on data shape —
 * all share the `recall("<key>"...)` prefix:
 *   - conversation-assembly:118  `recall("k", full: true)`     (inline-cap overflow)
 *   - tool-formatting:294/376    `recall("k", arrayStart: …)`  (array / commit data)
 *   - tool-formatting:410/437    `recall("k", start: 0, …)`    (object / CLI output)
 *   - context-curator:231        `recall("k")`                 (per-observation cap)
 * Matching the prefix (not one specific arg) keeps the gate fail-safe: when a key
 * is genuinely surfaced in ANY format, recall stays callable. Inline (≤cap) tool
 * results carry NO `recall(` marker, so they are still correctly gated out.
 */
const SURFACED_RECALL_KEY = /recall\("[^"]+"/;

/**
 * True iff some message in the current window surfaces a usable recall key
 * (a truncated/overflowed tool result carrying a `recall("<key>"…)` pointer in
 * any of its formats). Only `tool_result` content can surface a key — an
 * assistant turn merely mentioning recall does not count.
 */
export function recallKeyVisibleInWindow(
  windowMessages: readonly { readonly role: string; readonly content: unknown }[],
): boolean {
  return windowMessages.some(
    (m) =>
      m.role === "tool_result" &&
      typeof m.content === "string" &&
      SURFACED_RECALL_KEY.test(m.content),
  );
}

/**
 * Remove `recall` from a model-visible tool schema set unless it is actually
 * usable this iteration — i.e. a usable key is surfaced in the window, or
 * calibration force-enables it (`observationHandling === "uses-recall"`).
 * No-op when recall is not present.
 */
export function filterRecallByOverflow<T extends { readonly name: string }>(
  schemas: readonly T[],
  windowMessages: readonly { readonly role: string; readonly content: unknown }[],
  forceRecall = false,
): readonly T[] {
  if (!schemas.some((s) => s.name === "recall")) return schemas;
  if (forceRecall || recallKeyVisibleInWindow(windowMessages)) return schemas;
  return schemas.filter((s) => s.name !== "recall");
}

/**
 * Whether the recall-overflow gate is active for this run. DEFAULT-ON per the
 * Phase-3 cross-tier ablation (wiki/Research/Harness-Reports/phase3-ablation-2026-05-30.md):
 * opt-out only via `RA_RECALL_GATE=0`. Extracted as a named seam so the
 * default-on / opt-out behavior is directly testable.
 */
export const recallGateEnabled = (
  env: NodeJS.ProcessEnv = process.env,
): boolean => env.RA_RECALL_GATE !== "0";

// RA_ASSEMBLY flag REMOVED Sprint-1 A2 (2026-06-02). Canonical assembly
// is the sole path. The opt-out killswitch is gone — cross-tier finalbase
// (local/mid/frontier) cleared equal-or-better across every axis.
// See: wiki/Research/Harness-Reports/sprint1-canonical-collapse/
//      finalbase-{local,mid,frontier}.json + frontier-final.json.
