import { NoopEventLogger, type EventLogger } from '../progress/event-log.js';
import {
  OpenAICompatClient,
  Message,
  assistantToolCallMessage,
} from '../llm/openai-compat.js';
import { ToolContext } from './tools/index.js';
import { ContextManager, type ContextAction } from './context-manager.js';
import {
  guardPromptBeforeSend,
  PROMPT_GUARD_RATIO_DEFAULT,
} from './context/prompt-guard.js';
import { runIsolatedLlm as runIsolatedLlmHelper } from './llm-stream.js';
import {
  ABORT_CODE_CANCELLED,
  ABORT_CODE_DEADLINE,
  isDeadlineAbort,
} from './cancellation.js';
import { ToolResultCache } from './context/tool-result-cache.js';
import { Conversation } from './context/conversation.js';
import type { SafetyConfig } from '../config.js';
import { logger } from '../logger.js';
import {
  COMPLETE_TOOL_NAME,
  TRANSITION_TOOL_NAME,
  buildMovementResultFromComplete,
  classifyTerminalCalls,
  processTransitionCalls,
  pushRetryToolResults,
  recordControlToolResults,
  selectTerminalWinner,
} from './agent-loop/terminal-control.js';
import { dispatchRegularToolCalls } from './agent-loop/tool-dispatcher.js';
import {
  applyContextManagerUpdate,
  buildContextOverflowResult,
  handleLLMError,
} from './agent-loop/context-control.js';
import {
  buildMaxIterationsAbortMessage,
  createToolLoopTracker,
  handleTextOnlyResponse,
} from './agent-loop/loop-safety.js';
import { runDelegateSubAgent } from './agent-loop/delegate-runner.js';
import { createMovementWatchdogs } from './agent-loop/watchdogs.js';
import { injectPendingImages } from './agent-loop/image-context.js';
import { prepareMovement } from './agent-loop/movement-setup.js';
import { runLlmIteration } from './agent-loop/llm-iteration.js';
import {
  buildBrowserWaitResult,
  buildToolApprovalWaitResult,
  buildPackageApprovalWaitResult,
  buildSubtaskWaitResult,
  buildCompleteAutoParkResult,
} from './agent-loop/pending-states.js';

// Re-exported so existing callers (and the test file) keep working.
export { stripThinkingTokens } from './strip-thinking.js';
export {
  __setActiveSessionLookup,
  buildGlobalPreamble,
  buildMovementGuidance,
  buildSystemPrompt,
  type GlobalPreambleOpts,
} from './agent-loop/prompt.js';
import type {
  AgentLoopCallbacks,
  ExecuteMovementOptions,
  HandoffContext,
  LLMCallInfo,
  Movement,
  MovementResult,
  ToolResultInfo,
} from './agent-loop/types.js';
// Re-export the shared agent-loop types so existing callers (piece-runner,
// tests, …) keep importing them from './agent-loop.js' unchanged.
export type {
  AgentLoopCallbacks,
  ExecuteMovementOptions,
  HandoffContext,
  LLMCallInfo,
  Movement,
  MovementResult,
  ToolResultInfo,
} from './agent-loop/types.js';


const DEFAULT_MAX_ITERATIONS = 200;
/**
 * Default for safety.maxToolLoopRepeats: how many times the LLM may emit the
 * byte-identical regular tool-call batch on consecutive iterations before the
 * movement is force-aborted as a tool-call loop. Overridable per config and
 * from the Settings UI.
 */
const DEFAULT_MAX_TOOL_LOOP_REPEATS = 5;
/**
 * Maximum nesting depth for delegate sub-agents. At depth >= MAX_DELEGATION_DEPTH,
 * the 'delegate' tool is removed from the sub-agent's allowedTools so leaf
 * sub-agents cannot recurse further.
 */
export const MAX_DELEGATION_DEPTH = 2;

/**
 * Build the terminal result for an aborted-signal cancellation, distinguishing a
 * user cancel from the hard-deadline guard. The abortCode lets piece-runner map
 * this to the `cancelled` job status (see isCancellationAbortCode) and lets the
 * UI tell "you cancelled" apart from "the execution ceiling fired".
 */
function buildCancelResult(
  cancelSignal: AbortSignal | undefined,
  toolsUsed: string[],
): MovementResult {
  if (isDeadlineAbort(cancelSignal)) {
    return {
      next: 'ABORT',
      output: '実行時間の上限に達したため、ジョブを自動的に終了しました。',
      toolsUsed,
      abortCode: ABORT_CODE_DEADLINE,
    };
  }
  return {
    next: 'ABORT',
    output: 'Movement cancelled by caller',
    toolsUsed,
    abortCode: ABORT_CODE_CANCELLED,
  };
}

// --- メイン ---


export async function executeMovement(
  movement: Movement,
  taskInstruction: string,
  client: OpenAICompatClient,
  ctx: ToolContext,
  options: ExecuteMovementOptions = {},
): Promise<MovementResult> {
  const {
    callbacks,
    maxIterations = DEFAULT_MAX_ITERATIONS,
    contextManager,
    cancelSignal,
    cancelCheck,
    visitCount = 1,
    maxVisits = 5,
    safetyConfig,
    toolResultCache,
  } = options;
  const promptGuardRatio = safetyConfig?.promptGuardRatio ?? PROMPT_GUARD_RATIO_DEFAULT;
  // Fire 0% gauge once on first Movement when no LLM call has happened yet,
  // so the UI sees the context limit immediately. Skip this on later Movements
  // where contextManager already holds a non-zero promptTokens (don't reset to 0).
  if (contextManager && !contextManager.hasUsageData()) {
    callbacks?.onContextUpdate?.({
      promptTokens: 0,
      limitTokens: contextManager.getContextLimit(),
    });
  }
  const { regularTools, tools, messages } = await prepareMovement({
    movement,
    taskInstruction,
    ctx,
    conversation: options.conversation,
    priorTurns: options.priorTurns,
    handoffContext: options.handoffContext,
    visitCount,
    maxVisits,
  });

  logger.info(`[agent-loop] movement=${movement.name} tools=${tools.map(t => t.function.name).join(',')}`);
  const runIsolatedLlm = (isolatedMessages: Message[]): Promise<string> =>
    runIsolatedLlmHelper(client, isolatedMessages, cancelSignal, { userId: ctx.userId });

  // Traceability T-1: ensure eventLogger is non-undefined for the
  // duration of the movement. Production callers (piece-runner) always
  // pass one; minimal test ctx may not.
  const events: EventLogger = ctx.eventLogger ?? new NoopEventLogger();
  const movementEvents = events.child({ movement: movement.name });
  movementEvents.emit('movement_start', {
    pieceMovement: movement.name,
    visitCount,
    maxVisits,
    allowedTools: movement.allowedTools,
  });

  const toolCtx: ToolContext = {
    ...ctx,
    contextManager,
    runIsolatedLlm,
    eventLogger: movementEvents,
    // Cancel-traceability PR2: surface the cancel signal to tools so
    // long-running ones (Bash, future Playwright) actually stop instead
    // of running their spawned child to completion when the user cancels.
    abortSignal: cancelSignal,
  };

  // Phase B Task 3: inject ctx.runDelegate when the movement allows 'delegate'
  // and we haven't yet reached the recursion depth cap. The closure closes over
  // the local client + options so the sub-agent inherits the same LLM client and
  // runtime settings. toolCtx is the correct target (not the incoming ctx) because
  // the tools framework receives toolCtx, not the raw ctx passed by the caller.
  const currentDepth = ctx.delegationDepth ?? 0;
  if (currentDepth < MAX_DELEGATION_DEPTH && movement.allowedTools.includes('delegate')) {
    toolCtx.runDelegate = (params) =>
      runDelegateSubAgent(params, {
        client,
        parentCtx: toolCtx,
        parentMovement: movement,
        depth: currentDepth + 1,
        maxIterations: options.maxIterations,
        safetyConfig: options.safetyConfig,
        cancelSignal: options.cancelSignal,
        cancelCheck: options.cancelCheck,
        contextLimit: contextManager?.getContextLimit() ?? 128_000,
        parentCallbacks: options.callbacks,
        maxDelegationDepth: MAX_DELEGATION_DEPTH,
        executeSubMovement: executeMovement,
      });
  }

  const toolsUsed: string[] = [];
  let regularToolsUsed = 0; // transition 以外のツール使用回数
  const textOnlyRetries = { value: 0 };

  const maxToolLoopRepeats = safetyConfig?.maxToolLoopRepeats ?? DEFAULT_MAX_TOOL_LOOP_REPEATS;
  const toolLoopTracker = createToolLoopTracker({
    movementName: movement.name,
    limit: maxToolLoopRepeats,
  });
  const watchdogs = createMovementWatchdogs({
    workspacePath: ctx.workspacePath,
    missionWatchdogEnabled: !!ctx.missionBrief,
    missionAware: !!ctx.missionBrief?.read()?.goal,
  });

  // Traceability T-1: every return path goes through this helper so the
  // movement_complete event is emitted exactly once with a uniform shape.
  const finishMovement = (result: MovementResult): MovementResult => {
    options.conversation?.flush();
    movementEvents.emit('movement_complete', {
      next: result.next,
      outputPreview: typeof result.output === 'string' ? result.output : '',
      lessons: result.lessons ?? undefined,
      waitReason: result.waitReason ?? undefined,
      toolsUsed: result.toolsUsed,
    });
    callbacks?.onMovementComplete?.(movement.name, result);
    return result;
  };

  for (let iteration = 0; iteration < maxIterations; iteration++) {
    // キャンセルチェック: DB のジョブ状態を確認し、abort シグナルを発火
    cancelCheck?.();
    if (cancelSignal?.aborted) {
      logger.info(`[agent-loop] movement=${movement.name} cancelled by signal at iteration=${iteration} deadline=${isDeadlineAbort(cancelSignal)}`);
      return finishMovement(buildCancelResult(cancelSignal, toolsUsed));
    }

    // Interjection check: inject user messages sent during execution
    if (options.checkInterjections) {
      try {
        const interjections = await options.checkInterjections(movement.name);
        if (interjections.length > 0) {
          for (const msg of interjections) {
            messages.push({
              role: 'user',
              content: `[interjection] ${msg.body}`,
            });
          }
          logger.info(`[agent-loop] movement=${movement.name} injected ${interjections.length} user interjection(s) at iteration=${iteration}`);
        }
      } catch (err) {
        logger.warn(`[agent-loop] movement=${movement.name} interjection check failed: ${err}`);
      }
    }

    logger.info(`[agent-loop] movement=${movement.name} iteration=${iteration}`);

    watchdogs.maybeFire(iteration, movement.name, messages, movementEvents);

    const promptGuard = await guardPromptBeforeSend(messages, tools, contextManager, {
      promptGuardRatio,
      historySummarization: safetyConfig?.historySummarization,
      runIsolatedLlm,
    });
    if (!promptGuard.ok) {
      logger.warn(`[agent-loop] movement=${movement.name} oversized prompt blocked before send: estimated=${promptGuard.estimatedTokens} limit=${promptGuard.limitTokens}`);
      const result = await buildContextOverflowResult(
        movement,
        promptGuard.message,
        messages,
        toolsUsed,
        runIsolatedLlm,
      );
      return finishMovement(result);
    }
    if (promptGuard.compacted) {
      logger.warn(`[agent-loop] movement=${movement.name} compacted oversized tool results before LLM request estimated=${promptGuard.estimatedTokens}`);
    }
    if (promptGuard.deduped || promptGuard.compacted || promptGuard.summarized) {
      options.conversation?.rewrite();
    }

    const { accumulatedText, pendingToolCalls, hadError, errorMessage, lastUsage } =
      await runLlmIteration({
        client,
        messages,
        tools,
        cancelSignal,
        callbacks,
        toolsUsed,
        watchdogs,
        movementName: movement.name,
        iteration,
        events: movementEvents,
        userId: ctx.userId,
      });

    if (hadError) {
      // A deadline/user abort that fires mid-stream surfaces here as a stream
      // error ("Request cancelled by caller"). Classify it as a cancellation
      // (not a generic retryable llm_error) so the job ends cancelled/timeout
      // and carries the right abortCode for piece-runner + the UI.
      if (cancelSignal?.aborted) {
        return finishMovement(buildCancelResult(cancelSignal, toolsUsed));
      }
      const errorResult = await handleLLMError(errorMessage, {
        movement,
        messages,
        tools,
        toolsUsed,
        contextManager,
        promptGuardRatio,
        safetyConfig,
        runIsolatedLlm,
      });
      if (errorResult) {
        return finishMovement(errorResult);
      }
      continue; // recovery succeeded — re-enter the loop
    }

    if (contextManager) {
      const ctxResult = applyContextManagerUpdate(
        contextManager,
        lastUsage,
        iteration,
        movement,
        toolsUsed,
        messages,
        callbacks,
        movementEvents,
      );
      if (ctxResult) {
        return finishMovement(ctxResult);
      }
    }

    // tool_calls がある場合
    if (pendingToolCalls.length > 0) {
      textOnlyRetries.value = 0;
      const assistantMsg = assistantToolCallMessage(pendingToolCalls);
      if (accumulatedText) {
        assistantMsg.content = accumulatedText;
      }
      messages.push(assistantMsg);

      // Phase 6a §2.5: classify all tool calls in this iteration BEFORE
      // running side effects. The terminal-call winner is selected purely
      // from content (never from order), so we need everything in hand
      // before deciding whether the iteration ends terminally or continues.
      const flowControlCalls = pendingToolCalls.filter(
        (tc) => tc.function.name === TRANSITION_TOOL_NAME || tc.function.name === COMPLETE_TOOL_NAME,
      );
      const regularCalls = pendingToolCalls.filter(
        (tc) =>
          tc.function.name !== TRANSITION_TOOL_NAME &&
          tc.function.name !== COMPLETE_TOOL_NAME,
      );
      const classified = classifyTerminalCalls(flowControlCalls);

      const loopOutcome = toolLoopTracker.evaluate({
        regularCalls,
        hasFlowControlCalls: flowControlCalls.length > 0,
        iteration,
        toolsUsed,
        eventLogger: movementEvents,
      });
      if (loopOutcome.kind === 'abort') {
        return finishMovement(loopOutcome.result);
      }

      const dispatch = await dispatchRegularToolCalls(
        regularCalls,
        regularTools,
        toolCtx,
        messages,
        callbacks,
        regularToolsUsed,
        toolResultCache,
        movement.name,
      );
      // A deadline/user abort that fired *during* tool dispatch is now swallowed
      // by the tools themselves (②A: web/MCP/browser catch the abort and return
      // a normal ToolResult instead of throwing). Re-check here BEFORE any
      // wait/terminal handling: otherwise a `complete` (or a wait control call)
      // co-emitted in the same LLM response would settle the movement as
      // success/waiting and mask the cancellation — the worker would persist
      // 'succeeded' and raceWithGrace would never hard-kill.
      if (cancelSignal?.aborted) {
        return finishMovement(buildCancelResult(cancelSignal, toolsUsed));
      }
      if (dispatch.status === 'waiting_human') {
        return finishMovement(buildBrowserWaitResult(dispatch, toolsUsed, movement.name));
      }
      const toolApprovalWait = buildToolApprovalWaitResult(toolCtx, toolsUsed, movement.name);
      if (toolApprovalWait) return finishMovement(toolApprovalWait);
      const packageApprovalWait = buildPackageApprovalWaitResult(toolCtx, toolsUsed, movement.name);
      if (packageApprovalWait) return finishMovement(packageApprovalWait);
      const subtaskWait = buildSubtaskWaitResult(toolCtx, toolsUsed, movement.name);
      if (subtaskWait) return finishMovement(subtaskWait);
      regularToolsUsed += dispatch.regularToolsUsedDelta;
      injectPendingImages({
        pendingImages: dispatch.pendingImages,
        messages,
        availableTokens: contextManager?.getAvailableTokens() ?? Number.POSITIVE_INFINITY,
      });

      // Tool-loop progressive warning. Injected here (not before dispatch) so
      // it lands AFTER every tool_result for this iteration — keeping the
      // assistant/tool_result pairing intact.
      if (loopOutcome.kind === 'warn') {
        messages.push({
          role: 'user',
          content: `[loop watchdog] 同じツール呼び出し（${regularCalls.map((c) => c.function.name).join(', ')}）を ${loopOutcome.repeats} 回連続で繰り返しています。同じ引数で呼び続けると、あと ${loopOutcome.remaining} 回で強制中断されます。
直前の結果を読み直し、(1) 別のアプローチ・別の引数を試す、(2) 既に十分な情報が揃っているなら transition / complete で次に進む、のいずれかを選んでください。同じ呼び出しを繰り返さないでください。`,
        });
        logger.info(`[agent-loop] movement=${movement.name} tool-loop watchdog nudge at repeats=${loopOutcome.repeats}/${maxToolLoopRepeats}`);
        movementEvents.emit('watchdog_fire', { kind2: 'tool_loop', iteration, repeats: loopOutcome.repeats }, { iteration });
      }

      // Phase 6a §2.5 (post-6b): select winner from classified terminals.
      // Only `native_winner` exists now — the legacy shim path was removed.
      const winner = selectTerminalWinner(classified);

      if (winner.kind === 'native_winner') {
        // Codex #1: record a tool result for the winning complete (and any
        // superseded control calls) so the complete tool_call is never left
        // dangling in the shared conversation. The load-bearing in-process reuse
        // is the transition→next-movement path (handled in processTransitionCalls);
        // for complete this keeps the transcript honest and defends the auto-park
        // to WAIT_SUBTASKS. NOTE: the earlier waiting_human / tool-approval /
        // subtask-wait park exits above return before this line and abandon any
        // co-emitted control call — that is sanitized by Conversation
        // .replayableTurns on resume (control + dangling tool_calls are stripped),
        // so it never reaches the model; it is not normalized here.
        recordControlToolResults(messages, winner.toolCall, winner.ignoredCalls, `Completed with status="${winner.args.status}".`);
        // Auto-park guard: the agent tried to complete *successfully* while it
        // still has unwaited pending children. Convert to WAIT_SUBTASKS instead
        // of a false success, resuming the SAME movement so it can read
        // subtasks/*/result.md and genuinely finish. Only the success path is
        // parked here; abort / needs_user_input with pending children is handled
        // at the worker terminal chokepoint (cancelPendingSubtasks).
        const autoPark = await buildCompleteAutoParkResult(winner.args, toolCtx, toolsUsed, movement.name);
        if (autoPark) return finishMovement(autoPark);
        const result = buildMovementResultFromComplete(
          winner.args,
          toolsUsed,
        );
        logger.info(`[agent-loop] movement=${movement.name} native_winner complete → next=${result.next}`);
        movementEvents.emit('complete', {
          status: winner.args.status,
          result: winner.args.result,
          abortReason: winner.args.abort_reason,
          missingInfo: winner.args.missing_info,
          whyNoDefault: winner.args.why_no_default,
        });
        return finishMovement(result);
      }

      if (winner.kind === 'retry') {
        // Codex trap 1: every tool_use id needs a tool_result, otherwise the
        // next assistant message dangles a tool_call without a matching
        // tool_result and the provider rejects the conversation.
        logger.warn(`[agent-loop] movement=${movement.name} terminal retry: ${winner.reason}`);
        pushRetryToolResults(messages, winner.failingCalls, winner.ignoredCalls, winner.reason);
        // Loop continues to next iteration; LLM sees the errors and retries.
        continue;
      }

      // winner.kind === 'no_terminal' — fall through to the existing
      // non-terminal transition handling.
      const transitionResult = processTransitionCalls(
        classified.nonTerminalTransitions,
        movement,
        accumulatedText,
        toolsUsed,
        messages,
      );
      if (transitionResult) {
        movementEvents.emit('transition', {
          nextStep: transitionResult.next,
          outputPreview: transitionResult.output,
          lessons: transitionResult.lessons ?? undefined,
        });
        return finishMovement(transitionResult);
      }

    // テキストのみのレスポンス
    } else {
      const textOnlyOutcome = handleTextOnlyResponse(
        accumulatedText,
        movement,
        toolsUsed,
        messages,
        textOnlyRetries,
      );
      if (textOnlyOutcome.kind === 'abort') {
        return finishMovement(textOnlyOutcome.result);
      }
      continue;
    }
    options.conversation?.flush();
  }

  logger.warn(`[agent-loop] movement=${movement.name} exceeded maxIterations=${maxIterations}`);
  return finishMovement({ next: 'ABORT', output: buildMaxIterationsAbortMessage(movement.name, maxIterations, toolsUsed), toolsUsed, abortCode: 'max_iterations_exceeded' });
}
