/**
 * Map-phase executor. Drives a fan-out workload by:
 *   1. Calling a configured source tool ONCE (no model) to fetch items.
 *   2. Invoking the harness once per item with a constrained tool surface
 *      containing only the sink tool (and optional read-only tools).
 *   3. Aggregating per-item outcomes into a MapPhaseResult.
 *
 * The source tool is structurally absent from the per-item surface, which
 * eliminates the fetch-loop failure mode that motivated this design — the
 * model cannot call a tool that isn't in the surface.
 */

import { errorMessage as toErrorMessage } from '@myco/utils/error-message.js';
import { getAtPath } from '@myco/utils/dot-path.js';
import { interpolateArgs } from '@myco/utils/interpolate-args.js';
import { interpolate } from '@myco/utils/interpolate.js';
import type { AgentEmbeddingPort } from '@myco/agent/runtime/ports.js';
import { aggregateUsage } from './executor-state.js';
import { buildMapItemToolSurface } from './map-phase-tool-surface.js';
import {
  HarnessExecutionError,
  type AgentHarness,
  type HarnessExecuteResult,
  type HarnessScope,
} from './harness/types.js';
import type { MapPhaseResult, PhaseDefinition, ProviderConfig, RunLogger, RuntimeUsage } from './types.js';
import type { MycoToolDefinition } from '@myco/agent/tools/types.js';

export interface ExecuteMapPhaseInput {
  phase: PhaseDefinition;
  allTools: MycoToolDefinition<any>[];
  harness: AgentHarness;
  params: Record<string, unknown>;
  systemPrompt: string;
  runId: string;
  agentId: string;
  /** Resolved phase model (from outer phase resolution). Required for the harness adapter to pick the right model. Optional for stub-harness tests. */
  phaseModel?: string;
  /** Resolved provider config (task/phase override aware). Required for the harness adapter to pick the right backend. */
  provider?: ProviderConfig;
  /** Vault dir threaded into per-item toolSurface so freshly-built tools resolve project_id correctly. */
  vaultDir?: string;
  /** Project root threaded into per-item toolSurface (mirrors free-form path). */
  projectRoot?: string;
  /** Embedding manager threaded through so RAG-enabled tools work in flexible mode. */
  embeddingManager?: AgentEmbeddingPort;
  /** Run-level logger. Per-item failures emit debug entries through this. */
  logger?: RunLogger;
  /** Run-level abort controller. Aborting it stops current and future map items. */
  runAbortController?: AbortController;
}

export async function executeMapPhase(input: ExecuteMapPhaseInput): Promise<MapPhaseResult> {
  const {
    phase, allTools, harness, params, systemPrompt, runId, agentId,
    phaseModel, provider, vaultDir, projectRoot, embeddingManager, logger,
    runAbortController,
  } = input;
  if (phase.mode !== 'map' || !phase.source || !phase.item || !phase.sink) {
    throw new Error(`executeMapPhase: phase "${phase.name}" is not a complete map phase`);
  }

  throwIfRunAborted(runAbortController);
  const items = await fetchSourceItems({ phase, allTools, params });
  throwIfRunAborted(runAbortController);
  logger?.debug('agent.map.fetched', `Map phase "${phase.name}" fetched ${items.length} items`, {
    runId, phase: phase.name, itemCount: items.length, source: phase.source.tool,
  });

  const itemUsages: RuntimeUsage[] = [];
  const result: MapPhaseResult = {
    itemCount: items.length,
    written: 0,
    skipped: 0,
    failed: 0,
    abandoned: 0,
    skipReasons: {},
    writeAfterThrow: 0,
    usage: {},
  };

  const normalizedItemPrompt = normalizeTemplateBraces(phase.item.prompt);
  const sharedItemCtx: {
    argMap: Record<string, unknown>;
    capture: (outcome: { ok: boolean; reason?: string }) => void;
  } = {
    argMap: {},
    capture: () => undefined,
  };
  // The sink schema and read tools are fixed for the phase — only argMap
  // values change per item. Build the surface once; the wrapped sink reads
  // per-item state from sharedItemCtx.
  const sharedSurface = buildMapItemToolSurface(allTools, {
    sinkName: phase.sink.tool,
    argMap: phase.sink.argMap,
    readToolNames: phase.item.readTools ?? [],
  });
  const strippedSink = sharedSurface.tools.find((t) => t.name === phase.sink!.tool);
  if (!strippedSink) {
    throw new Error(`map-phase: stripped sink tool "${phase.sink.tool}" missing from per-item surface`);
  }
  const sharedSinkWrapped = wrapSinkWithMutableContext(
    sharedSurface.sinkTool,
    strippedSink,
    sharedItemCtx,
  );
  const sharedTools = sharedSurface.tools.map((t) =>
    t.name === phase.sink!.tool ? sharedSinkWrapped : t,
  );
  const sharedToolSurface = {
    agentId, runId,
    toolNames: sharedTools.map((t) => t.name),
    projectRoot, vaultDir, embeddingManager,
    tools: sharedTools,
  };

  let scope: HarnessScope | undefined;
  if (typeof harness.openScope === 'function') {
    scope = await harness.openScope({
      systemPrompt,
      model: phaseModel ?? '',
      provider,
      toolSurface: sharedToolSurface,
      logger,
    });
  }

  try {
  for (const item of items) {
    throwIfRunAborted(runAbortController);
    const argMap = interpolateArgs(phase.sink.argMap, { item, params });
    const itemPrompt = interpolate(
      normalizedItemPrompt,
      flattenForInterpolate({ item, params }),
    );

    let sinkOutcome: { ok: boolean; reason?: string } | undefined;
    sharedItemCtx.argMap = argMap;
    sharedItemCtx.capture = (outcome) => {
      if (sinkOutcome?.ok === true && outcome.ok !== true) return;
      sinkOutcome = outcome;
    };

    const controller = new AbortController();
    const detachRunAbort = linkRunAbort(runAbortController, controller);
    const timer = phase.perItemTimeoutSeconds && phase.perItemTimeoutSeconds > 0
      ? setTimeout(
          () => controller.abort(new Error('per-item timeout')),
          phase.perItemTimeoutSeconds * 1000,
        )
      : null;
    try {
      const itemResult: HarnessExecuteResult = scope
        ? await scope.run({
            prompt: itemPrompt,
            maxTurns: phase.perItemMaxTurns ?? 1,
            abortController: controller,
          })
        : await harness.execute({
            prompt: itemPrompt,
            systemPrompt,
            model: phaseModel ?? '',
            maxTurns: phase.perItemMaxTurns ?? 1,
            provider,
            toolSurface: sharedToolSurface,
            abortController: controller,
            logger,
          });
      if (itemResult.usage) itemUsages.push(itemResult.usage);
    } catch (err) {
      if (runAbortController?.signal.aborted) throw toAbortError(runAbortController.signal.reason);
      const reason = toErrorMessage(err);
      if (err instanceof HarnessExecutionError && err.telemetry?.usage) {
        itemUsages.push(err.telemetry.usage);
      }
      // If the wrapped sink already wrote successfully before the harness
      // threw, count as written — the data side succeeded, only the SDK
      // termination glitched.
      if (sinkOutcome?.ok === true) {
        result.written += 1;
        result.writeAfterThrow += 1;
        logger?.debug('agent.map.item-write-then-throw', `Map phase "${phase.name}" item wrote successfully then harness threw`, {
          runId, phase: phase.name, item: (item as any)?.path ?? null, reason,
        });
        continue;
      }
      logger?.debug('agent.map.item-failed', `Map phase "${phase.name}" item failed`, {
        runId, phase: phase.name, item: (item as any)?.path ?? null, reason,
      });
      if ((phase.onItemError ?? 'skip') === 'abort') {
        throw err;
      }
      result.failed += 1;
      continue;
    } finally {
      if (timer) clearTimeout(timer);
      detachRunAbort();
    }

    if (sinkOutcome?.ok === true) {
      result.written += 1;
    } else if (sinkOutcome) {
      result.skipped += 1;
      const reason = sinkOutcome.reason ?? 'unknown';
      result.skipReasons[reason] = (result.skipReasons[reason] ?? 0) + 1;
    } else {
      result.skipped += 1;
      result.skipReasons.no_terminal_tool = (result.skipReasons.no_terminal_tool ?? 0) + 1;
    }
  }
  } finally {
    if (scope) await scope.close();
  }

  result.usage = aggregateUsage(itemUsages);
  return result;
}

/**
 * Wrap the sink tool with a closure that reads its per-item argMap and
 * capture target from a mutable context object. Used in scoped mode so
 * the same wrapped sink instance can be embedded in the shared MCP server
 * yet still record per-item outcomes correctly when scope.run() is
 * called serially across N items.
 */
function wrapSinkWithMutableContext(
  sinkTool: MycoToolDefinition<any>,
  exposedTool: MycoToolDefinition<any>,
  ctx: {
    argMap: Record<string, unknown>;
    capture: (outcome: { ok: boolean; reason?: string }) => void;
  },
): MycoToolDefinition<any> {
  return {
    ...exposedTool,
    handler: async (modelArgs: Record<string, unknown>) => {
      const merged = { ...modelArgs, ...ctx.argMap };
      const response = await (sinkTool as any).handler(merged);
      const text = (response?.content?.[0] as any)?.text;
      if (typeof text === 'string') {
        try {
          const parsed = JSON.parse(text);
          ctx.capture({ ok: parsed.ok === true, reason: parsed.reason });
        } catch {
          ctx.capture({ ok: false, reason: 'sink_response_unparseable' });
        }
      } else {
        ctx.capture({ ok: false, reason: 'sink_response_missing_text' });
      }
      return response;
    },
  };
}

async function fetchSourceItems(input: {
  phase: PhaseDefinition;
  allTools: MycoToolDefinition<any>[];
  params: Record<string, unknown>;
}): Promise<unknown[]> {
  const { phase, allTools, params } = input;
  const sourceDef = allTools.find((t) => t.name === phase.source!.tool);
  if (!sourceDef) {
    throw new Error(`executeMapPhase: source tool "${phase.source!.tool}" not found in registry`);
  }
  const renderedArgs = interpolateArgs(phase.source!.args, { params });
  const response = await (sourceDef as any).handler(renderedArgs);
  const text = (response?.content?.[0] as any)?.text;
  if (typeof text !== 'string') {
    throw new Error(`executeMapPhase: source returned non-text payload`);
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch (err) {
    throw new Error(`executeMapPhase: source textResult is not JSON: ${(err as Error).message}`);
  }
  const items = getAtPath(parsed, phase.source!.itemsPath);
  if (!Array.isArray(items)) {
    throw new Error(`executeMapPhase: itemsPath "${phase.source!.itemsPath}" did not resolve to an array`);
  }
  return items;
}


// `interpolate` (the existing util) takes a flat string-keyed map. Flatten
// nested vars for compatibility — map-phase prompts use {{ item.path }}-style
// dotted lookups, so we flatten one level and key as `item.path`.
function flattenForInterpolate(vars: Record<string, unknown>): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(vars)) {
    if (v === null || v === undefined) continue;
    if (typeof v === 'object') {
      for (const [kk, vv] of Object.entries(v as Record<string, unknown>)) {
        out[`${k}.${kk}`] = stringify(vv);
      }
    } else {
      out[k] = stringify(v);
    }
  }
  return out;
}

// Normalize `{{ key }}` → `{{key}}` so that `interpolate()` (which matches
// without spaces) can substitute dotted keys like `item.path`.
function normalizeTemplateBraces(template: string): string {
  return template.replace(/\{\{\s*(.+?)\s*\}\}/g, '{{$1}}');
}

function stringify(v: unknown): string {
  if (v === null || v === undefined) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  return JSON.stringify(v);
}

function throwIfRunAborted(runAbortController?: AbortController): void {
  if (!runAbortController?.signal.aborted) return;
  throw toAbortError(runAbortController.signal.reason);
}

function toAbortError(reason: unknown): Error {
  if (reason instanceof Error) return reason;
  if (typeof reason === 'string' && reason.length > 0) return new Error(reason);
  return new Error('Agent run aborted');
}

function linkRunAbort(
  runAbortController: AbortController | undefined,
  itemController: AbortController,
): () => void {
  const signal = runAbortController?.signal;
  if (!signal) return () => undefined;

  const abortItem = () => {
    if (!itemController.signal.aborted) {
      itemController.abort(signal.reason ?? new Error('Agent run aborted'));
    }
  };

  if (signal.aborted) {
    abortItem();
    return () => undefined;
  }

  signal.addEventListener('abort', abortItem, { once: true });
  return () => signal.removeEventListener('abort', abortItem);
}
