import { randomUUID } from "node:crypto";
import {
  logger as frameworkLogger,
  type CraftContext,
  type EventName,
  type Principal,
} from "@routecraft/routecraft";
import { toAiInputSchema } from "../llm/structured-output.ts";
import type { FnHandlerContext } from "../fn/types.ts";
import type { AgentDispatchIdentity } from "./session.ts";
import type { ResolvedTool } from "./tools/selection.ts";

/**
 * Convert a list of `ResolvedTool` entries (the output of
 * `tools([...]).resolve(ctx)`) into a Vercel AI SDK tool map suitable
 * for `generateText({ tools })`.
 *
 * Each resulting tool runs:
 * 1. Emit `route:<routeId>:agent:tool:invoked` on the context bus so
 *    cross-cutting observability (telemetry, dashboards, audit) sees
 *    the call before the handler runs.
 * 2. The optional guard (registered via `tools([{ name, guard }])` or
 *    `tools([{ tagged, guard }])`). Throwing inside the guard surfaces
 *    back to the model as a tool error so the model can self-correct;
 *    the wrapper emits `agent:tool:error` before rethrowing.
 * 3. The underlying handler with `(input, fnHandlerContext)`. On
 *    success the wrapper emits `agent:tool:result`; on throw it
 *    emits `agent:tool:error` and rethrows.
 *
 * Schema validation is delegated to the SDK: when the model's tool-call
 * args fail to match `inputSchema`, the SDK reports a tool error to
 * the model without calling `execute`. Successful validation passes
 * the parsed value through to `execute`.
 *
 * @internal
 */
export async function buildVercelTools(
  resolved: ResolvedTool[],
  ctx: CraftContext | undefined,
  abortSignal: AbortSignal,
  dispatchIdentity?: AgentDispatchIdentity,
  principal?: Principal,
): Promise<Record<string, unknown>> {
  if (resolved.length === 0)
    return Object.create(null) as Record<string, unknown>;
  const { tool } = await import("ai");

  // Use a null-prototype object so dynamic tool names supplied at
  // resolution time can never mutate Object.prototype (e.g. a tool
  // accidentally named "__proto__" would otherwise be a vector for
  // prototype pollution).
  const out: Record<string, unknown> = Object.create(null);
  for (const r of resolved) {
    const guard = r.guard;
    const handler = r.handler;
    const baseCtx: FnHandlerContext = makeFnHandlerContext(
      r.name,
      abortSignal,
      principal,
    );
    out[r.name] = tool({
      description: r.description,
      inputSchema: toAiInputSchema(r.input) as Parameters<
        typeof tool
      >[0]["inputSchema"],
      execute: async (
        input: unknown,
        callOpts?: {
          abortSignal?: AbortSignal;
          toolCallId?: string;
          messages?: unknown[];
        },
      ) => {
        // Merge per-call options (the SDK passes its own abortSignal
        // and toolCallId per invocation) into the handler context so
        // a tool can react to per-step cancellation, not just the
        // session-wide signal captured at buildVercelTools time.
        const callCtx: FnHandlerContext = {
          ...baseCtx,
          abortSignal: callOpts?.abortSignal ?? baseCtx.abortSignal,
        };
        // The Vercel SDK passes a unique toolCallId per invocation;
        // synthesise one when absent so invoked → result events still
        // correlate (a shared empty-string id would alias every call).
        const toolCallId = callOpts?.toolCallId ?? randomUUID();
        const start = Date.now();

        if (ctx && dispatchIdentity) {
          ctx.emit(
            `route:${dispatchIdentity.routeId}:agent:tool:invoked` as EventName,
            {
              routeId: dispatchIdentity.routeId,
              exchangeId: dispatchIdentity.exchangeId,
              correlationId: dispatchIdentity.correlationId,
              toolCallId,
              toolName: r.name,
              input,
            },
          );
        }

        try {
          if (guard) await guard(input, callCtx);
          const output = await handler(input, callCtx);
          if (ctx && dispatchIdentity) {
            ctx.emit(
              `route:${dispatchIdentity.routeId}:agent:tool:result` as EventName,
              {
                routeId: dispatchIdentity.routeId,
                exchangeId: dispatchIdentity.exchangeId,
                correlationId: dispatchIdentity.correlationId,
                toolCallId,
                toolName: r.name,
                output,
                duration: Date.now() - start,
              },
            );
          }
          return output;
        } catch (err) {
          if (ctx && dispatchIdentity) {
            ctx.emit(
              `route:${dispatchIdentity.routeId}:agent:tool:error` as EventName,
              {
                routeId: dispatchIdentity.routeId,
                exchangeId: dispatchIdentity.exchangeId,
                correlationId: dispatchIdentity.correlationId,
                toolCallId,
                toolName: r.name,
                error: err,
                duration: Date.now() - start,
              },
            );
          }
          throw err;
        }
      },
    });
  }
  return out;
}

/**
 * Construct the synthetic `FnHandlerContext` handed to a tool's guard
 * and handler during agent dispatch. Mirrors the shape `testFn`
 * provides: `logger`, `abortSignal`, optional `principal` (carried
 * over from the dispatching exchange so guards can authorise without
 * re-reading the source request), optional `correlationId` (not yet
 * populated by the runtime), and optional `checkpointId`
 * (durable-agents epic).
 *
 * Intentionally does not expose the framework `CraftContext` to tool
 * handlers; built-in tool builders that need to forward to a route
 * (e.g. `directTool`) capture the context at resolve time and thread
 * it through their own closure.
 */
function makeFnHandlerContext(
  toolName: string,
  abortSignal: AbortSignal,
  principal: Principal | undefined,
): FnHandlerContext {
  return {
    logger: frameworkLogger.child({ tool: toolName }),
    abortSignal,
    ...(principal ? { principal: freezePrincipal(principal) } : {}),
  };
}

/**
 * Build a deep-frozen snapshot of the dispatching exchange's
 * principal so a tool handler that bypasses the `ReadonlyPrincipal`
 * type cannot mutate it at runtime.
 *
 * Clones first because `exchange.principal` is mutable by design
 * (the routecraft pipeline lets a `.process()` step attach a custom
 * principal), so freezing the live object in place would break
 * downstream steps.
 *
 * `claims` is deep-cloned via `structuredClone` so that nested
 * claim objects (e.g. `claims.perms.write`) are not shared with the
 * original principal: shallow-cloning the top-level keys would let
 * a tool handler mutate a nested value and have it leak back into
 * the dispatching exchange's principal. After cloning, the entire
 * snapshot is recursively frozen so any runtime mutation attempt
 * (top-level field, array entry, nested claim object) throws.
 *
 * Runs once per dispatch in `buildVercelTools` and the same frozen
 * reference is reused for every tool invocation in that dispatch.
 *
 * @internal
 */
function freezePrincipal(principal: Principal): Principal {
  const snapshot: Principal = { ...principal };
  if (snapshot.audience) snapshot.audience = [...snapshot.audience];
  if (snapshot.scopes) snapshot.scopes = [...snapshot.scopes];
  if (snapshot.roles) snapshot.roles = [...snapshot.roles];
  if (snapshot.claims) snapshot.claims = structuredClone(snapshot.claims);
  return deepFreeze(snapshot);
}

/**
 * Recursively `Object.freeze` an object, walking arrays and plain
 * object values. Cycles are guarded via a visited set so a
 * self-referential principal won't blow the stack. Functions and
 * primitive values are left as-is.
 *
 * Skips `ArrayBuffer.isView` values (TypedArray, DataView, Buffer):
 * `Object.freeze` on a typed array with elements throws because the
 * indexed properties on the backing buffer cannot be made
 * non-configurable. A JWT claim that smuggles binary data (CBOR, an
 * encrypted key, raw bytes) would otherwise crash the dispatch.
 * `structuredClone` (used in `freezePrincipal` for `claims`) already
 * gives us an independent copy of any TypedArray, so the caller's
 * bytes are not shared with the snapshot; tool code that wants
 * write-protection on a binary claim should treat its read as opaque.
 *
 * @internal
 */
function deepFreeze<T>(value: T, seen: WeakSet<object> = new WeakSet()): T {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value as object)) return value;
  if (ArrayBuffer.isView(value)) return value;
  seen.add(value as object);
  for (const key of Object.keys(value as object)) {
    const v = (value as Record<string, unknown>)[key];
    if (v !== null && typeof v === "object") deepFreeze(v, seen);
  }
  return Object.freeze(value);
}
