/**
 * @fileoverview Handler factory for tool definitions.
 * Constructs Context, checks inline auth, measures execution, formats response.
 * @module src/mcp-server/tools/utils/toolHandlerFactory
 */

import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
  CallToolResult,
  ContentBlock,
  ElicitRequestFormParams,
  ElicitRequestURLParams,
  ElicitResult,
  ServerNotification,
  ServerRequest,
} from '@modelcontextprotocol/sdk/types.js';

import { toJSONSchema, ZodError, type ZodObject, type ZodRawShape } from 'zod';

import { config } from '@/config/index.js';
import type { Context, ElicitFn, EnrichmentStore } from '@/core/context.js';
import { attachTypedFail, createContext, readEnrichmentStore } from '@/core/context.js';
import { buildRequestScopedNotifiers } from '@/mcp-server/notifications.js';
import { withRequiredScopes } from '@/mcp-server/transports/auth/lib/authUtils.js';
import type { StorageService } from '@/storage/core/StorageService.js';
import { type JsonRpcErrorCode, McpError } from '@/types-global/errors.js';
import { ErrorHandler } from '@/utils/internal/error-handler/errorHandler.js';
import type { Logger } from '@/utils/internal/logger.js';
import { measureToolExecution } from '@/utils/internal/performance.js';
import { requestContextService } from '@/utils/internal/requestContext.js';
import { ATTR_MCP_TOOL_ENRICHED } from '@/utils/telemetry/attributes.js';
import type { AnyToolDefinition } from './toolDefinition.js';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type SdkExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;

/** Services required by the handler factory to construct Context. */
export interface HandlerFactoryServices {
  /**
   * When true, surface `ctx.sessionId` even in stateless HTTP mode (per-request
   * generated token). Wired from `createApp({ context: { exposeStatelessSessionId } })`.
   * Default false — `ctx.sessionId` is only set when the session has
   * request-spanning lifetime (HTTP `stateful` / `auto` mode).
   */
  exposeStatelessSessionId?: boolean;
  logger: Logger;
  storage: StorageService;
}

/**
 * Per-server notifier closures bound at registration time, targeting
 * `server.send*ListChanged()` and `server.server.elicitInput(...)`.
 *
 * Split from {@link HandlerFactoryServices} so each per-request McpServer gets
 * its own notifier closures — preventing a concurrent registerAll() from
 * overwriting an in-flight handler's notifier target (and potentially
 * notifying the wrong server).
 *
 * The sync handler factory prefers request-scoped notifiers
 * ({@link buildRequestScopedNotifiers}, #135) and uses these only as a fallback;
 * the background auto-task path uses them directly (no request scope), so those
 * notifications deliver on stdio but drop under HTTP.
 *
 * `elicitInput` and `getClientCapabilities` are bound at registration time to
 * the per-server `Server` instance so `wrapElicit` can gate `ctx.elicit` on
 * the client's advertised capability and forward elicitation requests on the
 * wire. Both are undefined in the auto-task background path (no connected
 * client is available).
 */
export interface HandlerNotifiers {
  /** Bound to `server.server.elicitInput.bind(server.server)`. */
  elicitInput?: (params: ElicitRequestFormParams | ElicitRequestURLParams) => Promise<ElicitResult>;
  /** Bound to `server.server.getClientCapabilities.bind(server.server)`. */
  getClientCapabilities?: () => { elicitation?: unknown } | undefined;
  notifyPromptListChanged?: () => void;
  notifyResourceListChanged?: () => void;
  notifyResourceUpdated?: (uri: string) => void;
  notifyToolListChanged?: () => void;
}

// ---------------------------------------------------------------------------
// Default formatter
// ---------------------------------------------------------------------------

const defaultResponseFormatter = (result: unknown): ContentBlock[] => [
  { type: 'text', text: JSON.stringify(result, null, 2) },
];

// ---------------------------------------------------------------------------
// Error response shaping
// ---------------------------------------------------------------------------

/**
 * Pulls `data.recovery.hint` from an error data payload when present and
 * non-empty. Returns `undefined` otherwise. Used to mirror the hint into
 * `content[]` text so format()-only clients see the same recovery guidance
 * structuredContent-only clients receive via `error.data.recovery.hint`.
 */
function extractRecoveryHint(data: Record<string, unknown> | undefined): string | undefined {
  const hint = (data?.recovery as { hint?: unknown } | undefined)?.hint;
  return typeof hint === 'string' && hint.length > 0 ? hint : undefined;
}

/**
 * Shapes a `CallToolResult` for a tool error response with parity across both
 * surfaces clients forward to the agent:
 * - `content[]` — read by clients like Claude Desktop (markdown)
 * - `structuredContent.error` — read by clients like Claude Code (JSON)
 *
 * Both surfaces carry the same payload. When `data.recovery.hint` is present,
 * it is also mirrored into the `content[]` text so format()-only clients see
 * the recovery guidance.
 *
 * Note: `_meta.error` is intentionally NOT emitted — the error code, message,
 * and data live on `structuredContent.error` instead, mirroring the success
 * path's `structuredContent` surface.
 */
export function buildToolErrorResult(
  code: JsonRpcErrorCode,
  message: string,
  data: Record<string, unknown> | undefined,
): CallToolResult {
  const hint = extractRecoveryHint(data);
  const text = hint ? `Error: ${message}\n\nRecovery: ${hint}` : `Error: ${message}`;
  return {
    isError: true,
    content: [{ type: 'text', text }],
    structuredContent: {
      error: {
        code,
        message,
        ...(data !== undefined && { data }),
      },
    },
  };
}

/**
 * Builds an error `CallToolResult` from a raw thrown value. Classifies via
 * {@link ErrorHandler.classifyOnly} when the value isn't already an
 * `McpError`. Only propagates data from `McpError` (its declared `data`) or
 * `ZodError` (its `issues`) — other thrown values get a `structuredContent.error`
 * with `code` and `message` only, so internal classification context never
 * leaks to clients.
 *
 * Use after invoking {@link ErrorHandler.handleError} for OTel/logging side
 * effects — this helper does not log.
 */
export function classifyAndBuildToolErrorResult(error: unknown): CallToolResult {
  if (error instanceof McpError) {
    return buildToolErrorResult(error.code, error.message, error.data);
  }
  const { code, message } = ErrorHandler.classifyOnly(error);
  const data = error instanceof ZodError ? { issues: error.issues } : undefined;
  return buildToolErrorResult(code, message, data);
}

// ---------------------------------------------------------------------------
// Success response shaping — enrichment merge + trailer
// ---------------------------------------------------------------------------

/**
 * The output schema advertised to clients in `tools/list`. When a tool declares
 * an `enrichment` block, that's `output.extend(enrichment)` so enrichment fields
 * appear in the advertised `outputSchema`; otherwise the bare `output`.
 */
export function effectiveOutputSchema(def: AnyToolDefinition): ZodObject<ZodRawShape> {
  if (!def.enrichment) return def.output;
  return def.output.extend(def.enrichment) as ZodObject<ZodRawShape>;
}

/** Renders a non-kind-tagged enrichment value as compact trailer text. */
function formatEnrichmentScalar(value: unknown): string {
  if (value == null) return String(value);
  if (typeof value === 'object') return JSON.stringify(value);
  return String(value);
}

/**
 * Renders accumulated enrichment as a single `content[]` trailer block, so
 * `content[]`-only clients see the same context `structuredContent` clients get
 * from the merged output. Per field, rendering resolves in order:
 *   1. a per-field `enrichmentTrailer.render` (the author's full control; wins
 *      over everything — the escape hatch for structured fields that would
 *      otherwise JSON-blob),
 *   2. the kind-tag set by a field-helper (notice → blockquote, total →
 *      "N total", echo → "Query: …", delta → "field: before → after"),
 *   3. the generic `**key:** value` fallback (key overridable via `label`),
 *      `JSON.stringify`-ing non-scalars.
 *
 * `parsed` is the validated effective output, so renderers receive the typed,
 * post-parse value rather than the raw accumulator entry. The rendered markdown
 * is appended to `content[]` only — `structuredContent` is built separately from
 * the raw merged values and never carries this trailer text. Returns `[]` when
 * nothing was enriched.
 */
function renderEnrichmentTrailer(
  store: EnrichmentStore,
  trailer: AnyToolDefinition['enrichmentTrailer'],
  parsed: Record<string, unknown>,
): ContentBlock[] {
  const lines: string[] = [];
  for (const key of Object.keys(store.values)) {
    // Skip keys the effective-output parse stripped (enriched but not declared in
    // the block) — the trailer mirrors what reached structuredContent.
    if (!(key in parsed)) continue;
    const value = parsed[key];
    const cfg = trailer?.[key];
    if (cfg?.render) {
      lines.push(cfg.render(value));
      continue;
    }
    switch (store.kinds.get(key)) {
      case 'notice':
        lines.push(`> ${String(value)}`);
        break;
      case 'total':
        lines.push(`**${String(value)} total**`);
        break;
      case 'echo':
        lines.push(`Query: ${String(value)}`);
        break;
      case 'delta': {
        const d = (value ?? {}) as { after?: unknown; before?: unknown };
        lines.push(
          `**${cfg?.label ?? key}:** ${formatEnrichmentScalar(d.before)} → ${formatEnrichmentScalar(d.after)}`,
        );
        break;
      }
      default:
        lines.push(`**${cfg?.label ?? key}:** ${formatEnrichmentScalar(value)}`);
    }
  }
  return lines.length > 0 ? [{ type: 'text', text: lines.join('\n') }] : [];
}

/**
 * Shapes the success `CallToolResult` surfaces from the validated domain payload
 * plus any accumulated enrichment:
 * - `structuredContent` — domain output merged with enrichment (validated against
 *   `output.extend(enrichment)`); the bare domain output when no enrichment block.
 * - `content[]` — the caller-rendered domain content (from `format()` or the
 *   JSON-stringify default, applied to the **domain payload only**) with the
 *   enrichment trailer always appended. Rendering the domain payload — never the
 *   merged object — keeps enrichment out of the JSON blob and avoids double-render.
 *
 * A required enrichment field the handler never populated fails the parse here,
 * surfacing the authoring bug as a loud error rather than dropping it silently.
 */
export function buildToolSuccessResult(
  def: AnyToolDefinition,
  ctx: Context,
  domainValidated: Record<string, unknown>,
  domainContent: ContentBlock[],
): Pick<CallToolResult, 'content' | 'structuredContent'> {
  if (!def.enrichment) {
    return { structuredContent: domainValidated, content: domainContent };
  }
  const store = readEnrichmentStore(ctx);
  const values = store?.values ?? {};
  const structuredContent = effectiveOutputSchema(def).parse({
    ...domainValidated,
    ...values,
  }) as Record<string, unknown>;
  const trailer =
    store && Object.keys(values).length > 0
      ? renderEnrichmentTrailer(store, def.enrichmentTrailer, structuredContent)
      : [];
  return {
    structuredContent,
    content: trailer.length > 0 ? [...domainContent, ...trailer] : domainContent,
  };
}

// ---------------------------------------------------------------------------
// Capability detection helpers
// ---------------------------------------------------------------------------

/**
 * Builds `ctx.elicit` from the notifiers bound at registration time.
 *
 * Returns `undefined` when:
 * - `elicitInput` was not bound (auto-task background path, or no server),
 * - the client did not advertise the elicitation capability (checked at
 *   request time, after the initialize handshake populates capabilities).
 *
 * When defined, the returned function is an `ElicitFn`: directly callable for
 * form-mode elicitation (schema converted from ZodObject to the restricted
 * flat JSON Schema the MCP spec requires), plus a `.url(message, url)` helper
 * for URL-mode elicitation (elicitationId generated internally).
 *
 * CRITICAL: `requestedSchema` must be a plain JSON Schema object in the spec's
 * restricted flat form — passing a raw ZodObject causes AJV validation to
 * reject the request on the SDK side. `z.toJSONSchema(schema)` produces the
 * correct shape.
 */
function wrapElicit(notifiers: HandlerNotifiers): ElicitFn | undefined {
  const { elicitInput, getClientCapabilities } = notifiers;
  if (typeof elicitInput !== 'function') return;

  // Capability check runs at request time (not registration time) so that
  // capabilities populated during the initialize handshake are visible.
  if (!getClientCapabilities?.()?.elicitation) return;

  const formFn = (msg: string, schema: ZodObject<ZodRawShape>): Promise<ElicitResult> => {
    const requestedSchema = toJSONSchema(schema) as ElicitRequestFormParams['requestedSchema'];
    return elicitInput({ message: msg, requestedSchema });
  };

  const urlFn = (msg: string, url: string): Promise<ElicitResult> => {
    const elicitationId = crypto.randomUUID();
    return elicitInput({ mode: 'url', message: msg, elicitationId, url });
  };

  const elicitFn = formFn as ElicitFn;
  elicitFn.url = urlFn;
  return elicitFn;
}

// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------

/**
 * Creates an MCP tool handler from a tool definition.
 * The returned function is compatible with the MCP SDK's ToolCallback type.
 *
 * Responsibilities:
 * - Creates RequestContext from SDK context (for tracing)
 * - Creates unified Context (for the handler)
 * - Checks inline `auth` scopes if defined
 * - Validates input via Zod schema
 * - Measures execution time
 * - Formats response via `format` or JSON default
 * - Catches errors and returns `isError: true`
 */
export function createToolHandler(
  def: AnyToolDefinition,
  services: HandlerFactoryServices,
  notifiers: HandlerNotifiers,
): (input: Record<string, unknown>, extra: SdkExtra) => Promise<CallToolResult> {
  const formatter = def.format ?? defaultResponseFormatter;

  return async (input, callContext): Promise<CallToolResult> => {
    // The SDK types `extra` as Record<string, unknown> at the boundary
    const sdkContext = callContext as unknown as SdkExtra;

    // Route handler-time list-changed / resource-updated notifications through
    // this request's `extra.sendNotification` so they carry `relatedRequestId`
    // and reach the client under the per-request HTTP/Worker McpServer model
    // (#135). Fall back to the server-level notifiers when the extra exposes no
    // sender (non-request scopes) — those deliver on stdio but drop under HTTP.
    const effectiveNotifiers = buildRequestScopedNotifiers(sdkContext) ?? notifiers;

    const sdkSessionId =
      typeof sdkContext?.sessionId === 'string' ? sdkContext.sessionId : undefined;

    // Surface sessionId on `Context` only when it has request-spanning
    // lifetime — stateful HTTP (or `auto`, which resolves to stateful for
    // HTTP). In stateless mode the SDK still hands us a per-request token;
    // pass it through only when the consumer opted in via
    // `createApp({ context: { exposeStatelessSessionId: true } })`. Stdio
    // gives no sessionId at the SDK layer, so the gate is moot there.
    const isStatefulMode = config.mcpSessionMode === 'stateful' || config.mcpSessionMode === 'auto';
    const handlerSessionId =
      sdkSessionId && (isStatefulMode || services.exposeStatelessSessionId === true)
        ? sdkSessionId
        : undefined;

    // Create internal RequestContext for tracing. Raw `input` is intentionally
    // excluded — it flows into the completion log via context spread and can
    // contain caller PII or secrets. Input size and top-level parameter names
    // are captured as OTel metric attributes in measureToolExecution instead.
    // Log correlation always uses the raw SDK sessionId — useful even in
    // stateless mode for tracing the SDK's per-request token through events.
    const appContext = requestContextService.createRequestContext({
      parentContext: {
        ...(typeof sdkContext?.requestId === 'string' ? { requestId: sdkContext.requestId } : {}),
        ...(sdkSessionId ? { sessionId: sdkSessionId } : {}),
      },
      operation: 'HandleToolRequest',
      additionalContext: { toolName: def.name, sessionId: sdkSessionId },
    });

    try {
      // Check inline auth scopes
      if (def.auth && def.auth.length > 0) {
        withRequiredScopes(def.auth, appContext);
      }

      // Validate input
      const validatedInput = def.input.parse(input);

      // Construct Context with detected capabilities. When the definition
      // declares an error contract, `attachTypedFail` adds `ctx.fail` so
      // handlers can `throw ctx.fail('reason', ...)`; otherwise ctx is unchanged.
      const ctx = attachTypedFail(
        createContext({
          appContext,
          logger: services.logger,
          storage: services.storage,
          signal: sdkContext.signal,
          sessionId: handlerSessionId,
          elicit: wrapElicit(notifiers),
          notifyPromptListChanged: effectiveNotifiers.notifyPromptListChanged,
          notifyResourceListChanged: effectiveNotifiers.notifyResourceListChanged,
          notifyResourceUpdated: effectiveNotifiers.notifyResourceUpdated,
          notifyToolListChanged: effectiveNotifiers.notifyToolListChanged,
        }),
        def.errors,
      );

      // Execute handler with performance measurement.
      // Wrap with Promise.resolve — handler may return sync or async.
      const result = await measureToolExecution(
        () => Promise.resolve(def.handler(validatedInput, ctx)),
        { ...appContext, toolName: def.name },
        validatedInput,
        () => {
          const store = readEnrichmentStore(ctx);
          return store && Object.keys(store.values).length > 0
            ? { [ATTR_MCP_TOOL_ENRICHED]: true }
            : {};
        },
      );

      const validatedResult = def.output.parse(result);

      // Render content[] from the domain payload only (Resolution B). Isolate
      // formatter errors from handler errors so they get classified correctly.
      let domainContent: ContentBlock[];
      try {
        domainContent = formatter(validatedResult);
      } catch (formatError) {
        throw new Error(
          `Output formatting failed: ${formatError instanceof Error ? formatError.message : String(formatError)}`,
        );
      }

      // Merge enrichment into structuredContent and append the content[] trailer.
      return buildToolSuccessResult(
        def,
        ctx,
        validatedResult as Record<string, unknown>,
        domainContent,
      );
    } catch (error: unknown) {
      ErrorHandler.handleError(error, {
        operation: `tool:${def.name}`,
        context: appContext,
      });
      return classifyAndBuildToolErrorResult(error);
    }
  };
}
