import { createMcpAnalytics, MCPAnalyticsConfig } from "@amplitude/mcp-analytics";
import type { AmplitudeClientLike, AmplitudeEvent } from "@amplitude/mcp-analytics";
import { createServerContext } from "@amplitude/mcp-analytics/context";
import type { McpRequestMethod, McpServerContext } from "@amplitude/mcp-analytics/context";
import { findTool, isToolArgument } from "./tools";
import { ADDRESS_RE, unknownArgs } from "./tools/shared";
import type { ToolCallResult, ToolDefinition } from "./tools/shared";
import type { ActionWarning, Env } from "./types";

const endpointFor = (env: Env): string =>
  env.AMPLITUDE_SERVER_ZONE === "EU" ? "https://api.eu.amplitude.com/2/httpapi" : "https://api2.amplitude.com/2/httpapi";

// A wallet address may reach an event without any call site choosing to send one: an upstream
// rejection is reported verbatim to the caller, and the SDK autocaptures that same text as
// [MCP] Error Message. Redacting here rather than at each producer is what makes "no address
// leaves the Worker" one function and one test instead of a rule every future dimension must obey.
const ADDRESS_IN_TEXT = /0x[0-9a-fA-F]{40}/g;
const ADDRESS_PLACEHOLDER = "<address>";

const redactValue = (value: unknown): unknown => {
  if (typeof value === "string") return value.replace(ADDRESS_IN_TEXT, ADDRESS_PLACEHOLDER);
  if (Array.isArray(value)) return value.map(redactValue);
  return value;
};

// Properties are flat maps of scalars and string arrays, so one level covers them.
const redactProperties = (props: unknown): unknown => {
  if (typeof props !== "object" || props === null) return props;
  return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, redactValue(v)]));
};

export const redactAddresses = (event: AmplitudeEvent): AmplitudeEvent => ({
  ...event,
  ...(event.event_properties ? { event_properties: redactProperties(event.event_properties) as Record<string, unknown> } : {}),
  ...(event.user_properties ? { user_properties: redactProperties(event.user_properties) as Record<string, unknown> } : {}),
});

// The SDK talks to Amplitude through this two-method contract, which lets us skip
// @amplitude/analytics-node entirely: that package posts over node:http and flushes on a
// process-lifetime timer, and a Worker has neither. Events are collected during the request and
// posted once from ctx.waitUntil, so analytics never sits in front of the tool response.
export class WorkerAmplitudeClient implements AmplitudeClientLike {
  private queue: AmplitudeEvent[] = [];

  constructor(private readonly env: Env) {}

  // No key means analytics is off: local dev, or before the secret is set.
  private get enabled(): boolean {
    return Boolean(this.env.AMPLITUDE_API_KEY);
  }

  track(event: AmplitudeEvent): void {
    if (!this.enabled) return;
    this.queue.push({ ...redactAddresses(event), time: Date.now(), platform: "mcp" });
  }

  async flush(): Promise<void> {
    if (this.queue.length === 0) return;
    const events = this.queue;
    this.queue = [];
    try {
      const res = await fetch(endpointFor(this.env), {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ api_key: this.env.AMPLITUDE_API_KEY, events }),
      });
      // Amplitude answers 4xx for a malformed payload or a bad key. Silence there means the events
      // are gone with no trace, so the status is worth a line even though nothing can retry it.
      if (!res.ok) console.error(`amplitude rejected ${events.length} event(s): ${res.status}`);
    } catch (e: any) {
      console.error("amplitude flush failed:", e?.message ?? e);
    }
  }
}

export type Analytics = ReturnType<typeof createMcpAnalytics>;

export const createAnalytics = (
  env: Env,
  serverName: string,
  serverVersion: string,
): { analytics: Analytics; amplitude: WorkerAmplitudeClient } => {
  const amplitude = new WorkerAmplitudeClient(env);
  const analytics = createMcpAnalytics({
    amplitude,
    serverName,
    serverVersion,
    config: new MCPAnalyticsConfig({
      // The server is stateless: one Server and transport per HTTP request, so the transport closes
      // on every response and the SDK's session hooks would report a session per request. Session
      // Initialized is emitted from the initialize request instead (see mcp-server.ts).
      autocapture: { sessionLifecycle: false, toolsListed: true, toolCalls: true },
      // A request with no wallet and no session id to anchor on gets a device_id that never recurs,
      // so it counts as a new device. Emitting anyway: half the tools never take a wallet, and they
      // are the exploratory ones (get_chains, get_markets, get_reserve_details) whose volume is
      // worth the most. Dropped events cannot be recovered, an inflated count can be filtered - the
      // events carry [MCP] Anchor Type: anonymous, so exclude that to read user counts.
      emitAnonymousEvent: true,
    }),
  });
  return { analytics, amplitude };
};

// Events emitted by hand. The SDK autocaptures tool calls and tools/list only, so prompts and
// resources would otherwise be invisible.
export const EVENTS = {
  sessionInitialized: "[MCP] Session Initialized",
  promptsListed: "[MCP] Prompts Listed",
  promptRetrieved: "[MCP] Prompt Retrieved",
  promptRejected: "[MCP] Prompt Rejected",
  resourcesListed: "[MCP] Resources Listed",
  resourceTemplatesListed: "[MCP] Resource Templates Listed",
  resourceRead: "[MCP] Resource Read",
  // The three ways a call came back wrong. The SDK's Tool Call Rejected and Tool Call Response
  // already say that one did; these say which part of the arguments, which is what names the
  // description to fix. Every failure this server has found came from measuring what callers sent.
  toolArgumentsRejected: "[MCP] Tool Arguments Rejected",
  upstreamInputRejected: "[MCP] Upstream Input Rejected",
  actionWarning: "[MCP] Action Warning",
} as const;

// Caller properties beat the SDK's reserved ones on collision, so none of these may reuse a
// DefaultServerFields name (serverName, sessionId, anchorType, protocolVersion, ...).
export const DIMENSIONS = {
  method: "[MCP] Method",
  requestedProtocolVersion: "[MCP] Requested Protocol Version",
  promptCount: "[MCP] Prompt Count",
  promptName: "[MCP] Prompt Name",
  rejectionReason: "[MCP] Rejection Reason",
  resourceCount: "[MCP] Resource Count",
  resourceTopic: "[MCP] Resource Topic",
  resourceFound: "[MCP] Resource Found",
  // The SDK writes this one on tool-scope events only, never on a server-scope event, so setting it
  // here overwrites nothing and a rejection breaks down beside the calls it came from. Only ever a
  // registered name: a tool a caller invented stays on the SDK's own Attempted Tool Name.
  toolName: "[MCP] Tool Name",
  rejectedArgument: "[MCP] Rejected Argument",
  upstreamRejection: "[MCP] Upstream Rejection",
  warningCode: "[MCP] Warning Code",
  warningLevel: "[MCP] Warning Level",
} as const;

// Fixed set, not free text: a rejection reason has to stay usable as a breakdown dimension.
export type PromptRejection = "unknown_prompt" | "missing_arguments" | "invalid_wallet" | "invalid_version";

/** The same idea for tool arguments. Fixed, and every value fires at a real rejection site. */
export type ToolRejection =
  | "version_not_accepted"
  | "unknown_argument"
  | "max_not_applicable"
  | "permit_not_applicable"
  | "arguments_not_combinable"
  | "ambiguous_reserve_selector"
  | "missing_reserve_selector"
  | "placeholder_address"
  | "invalid_address"
  | "invalid_signature"
  | "invalid_amount"
  | "invalid_slippage"
  | "invalid_version"
  | "invalid_chain_id"
  | "invalid_id"
  | "invalid_flag"
  | "invalid_filter"
  | "invalid_enum"
  | "invalid_number"
  | "other";

// Read off the message the handler already wrote, first match wins. Nothing derives from this table -
// no schema enum, no union, no runtime guard - so naming an argument in it duplicates no vocabulary,
// it only reads one. Conditional applicability comes first, because "'max' is only valid for withdraw
// or repay" names an argument whose shape was never the problem, and a shape rule would answer the
// wrong question. A wording that drifts out of these lands in "other", which is visible in the data;
// the cases below are pinned in test/analytics.test.ts so a rename fails the gate instead.
const REJECTION_PATTERNS: readonly (readonly [RegExp, ToolRejection])[] = [
  [/takes no 'version' argument/, "version_not_accepted"],
  [/^Unknown argument\(s\) for /, "unknown_argument"],
  [/'max' is only valid|'max' applies to|cannot repay the native token with 'max'/, "max_not_applicable"],
  [/'permitSignature' (?:applies to|does not apply)/, "permit_not_applicable"],
  [/must be supplied together|not both|requires version|is required when/, "arguments_not_combinable"],
  [/^Ambiguous:/, "ambiguous_reserve_selector"],
  [/requires 'reserve'|requires 'market'|requires 'collateral' and 'debt'|^Missing the reserve/, "missing_reserve_selector"],
  [/placeholder address/, "placeholder_address"],
  [/0x-prefixed 40-hex-char address|market pool address|0x token address/, "invalid_address"],
  [/signature|'permitDeadline'/i, "invalid_signature"],
  [/'amount'/, "invalid_amount"],
  [/'slippagePct'/, "invalid_slippage"],
  [/'version'|only and cannot be called with version/, "invalid_version"],
  [/'chainId'/, "invalid_chain_id"],
  [/^Missing(?: or invalid)? '|'txHash'/, "invalid_id"],
  [/expected a boolean/, "invalid_flag"],
  [/'symbols'|'search'|'operations'/, "invalid_filter"],
  [/'action'|'side'|'kind'|'route'|'window'|'state'|'topic'/, "invalid_enum"],
  [/expected an? (?:non-negative |positive )?integer/, "invalid_number"],
];

export const classifyRejection = (message: string): ToolRejection =>
  REJECTION_PATTERNS.find(([re]) => re.test(message))?.[1] ?? "other";

// Our own messages quote the arguments they are about. Kept only when some registered schema declares
// the name, because a caller can invent names without limit and the dimension may not grow with them,
// and only when the message named exactly one: "v3 requires 'market', 'token', and 'chainId'" is about
// three, so reporting the first would say the caller got 'market' wrong when it supplied none of them.
const QUOTED = /'([^']+)'/g;

const rejectedArgument = (
  reason: ToolRejection,
  message: string,
  def: ToolDefinition,
  args: unknown,
): string | undefined => {
  // These two are the caller's own key rather than anything we wrote, so they come from the arguments.
  if (reason === "version_not_accepted") return "version";
  if (reason === "unknown_argument") return unknownArgs(def, args).find(isToolArgument) ?? "other";
  const named = [...new Set([...message.matchAll(QUOTED)].map((m) => m[1]).filter(isToolArgument))];
  return named.length === 1 ? named[0] : undefined;
};

// Warnings ride inside the payload rather than beside it, at the top of `data` or one level in where
// a fan-out nests them per version. Bounded on both axes so a large read cannot turn a metric into
// latency, and gated on the serialized text below so most calls never walk at all.
const WALK_DEPTH = 6;
const WALK_NODES = 2000;
// The codes are our own constants. The shape check is what keeps that true the day one is built from
// a value we do not own, which would otherwise put unbounded text on a breakdown dimension.
const WARNING_CODE_RE = /^[A-Z][A-Z0-9_]{0,39}$/;

const isWarning = (v: unknown): v is ActionWarning =>
  typeof v === "object" && v !== null && typeof (v as any).code === "string" && typeof (v as any).level === "string";

const collectWarnings = (node: unknown, depth: number, budget: { left: number }, out: ActionWarning[]): void => {
  if (depth > WALK_DEPTH || budget.left <= 0 || typeof node !== "object" || node === null) return;
  budget.left--;
  if (Array.isArray(node)) {
    for (const item of node) collectWarnings(item, depth + 1, budget, out);
    return;
  }
  for (const [key, value] of Object.entries(node)) {
    if (key === "warnings" && Array.isArray(value)) {
      for (const w of value) if (isWarning(w)) out.push(w);
      continue;
    }
    collectWarnings(value, depth + 1, budget, out);
  }
};

/**
 * The findings that say the call cannot succeed as built, deduplicated by code.
 *
 * Only error level: an APPROVAL_REQUIRED rides on every first supply a wallet ever makes, so counting
 * it would measure wallets rather than the interface. The level still goes on the event, so a reader
 * sees the severity rather than inferring it from which findings we chose to emit.
 */
// Error-level only, deduped, and every code shape-checked so one ever built from a value we do not
// own cannot inflate the dimension.
export const normalizeWarnings = (found: ActionWarning[]): ActionWarning[] => {
  const seen = new Set<string>();
  const out: ActionWarning[] = [];
  for (const w of found) {
    if (w.level !== "error") continue;
    const code = WARNING_CODE_RE.test(w.code) ? w.code : "other";
    if (seen.has(code)) continue;
    seen.add(code);
    out.push({ ...w, code });
  }
  return out;
};

export const blockingWarnings = (result: unknown): ActionWarning[] => {
  const r = result as { content?: { text?: unknown }[]; structuredContent?: unknown } | undefined;
  const text = r?.content?.[0]?.text;
  if (typeof text !== "string" || !text.includes('"warnings"')) return [];
  const found: ActionWarning[] = [];
  collectWarnings(r?.structuredContent, 0, { left: WALK_NODES }, found);
  return normalizeWarnings(found);
};

export type EmitServerEvent = (method: McpRequestMethod, event: string, properties?: Record<string, unknown>) => void;

/** What one HTTP request knows about itself, as the events need to see it. */
export interface ServerScope {
  serverName: string;
  serverVersion: string;
  request: Request;
  /** Absent when the request carries no session to anchor on. */
  sessionId?: string;
  /** Per-request subject used only when there is no session. */
  anonymousId: string;
  protocolVersion: string;
  /** From initialize; empty on every later request. */
  clientInfo: Record<string, any>;
}

// One place, because these events must land on the same device the SDK resolves for the tool calls
// around them. Two anchors would split one session in two.
const serverContext = (scope: ServerScope): McpServerContext =>
  createServerContext({
    server: { name: scope.serverName, version: scope.serverVersion },
    transport: "streamable-http",
    protocolVersion: scope.protocolVersion,
    client: {
      name: typeof scope.clientInfo.name === "string" ? scope.clientInfo.name : undefined,
      version: typeof scope.clientInfo.version === "string" ? scope.clientInfo.version : undefined,
      userAgent: scope.request.headers.get("user-agent") ?? undefined,
    },
    // Naming the session as the device is what stitches these to the tool calls, which resolve the
    // same id via instrumentServer. With no session, the anonymous subject is minted here rather
    // than left to the factory's floor: trackServerEvent drops a floored context outright, where
    // the SDK's own autocapture honours emitAnonymousEvent and emits. Leaving that alone would make
    // prompts and resources look unused on exactly the requests that count tools/list as used.
    // Fresh per request by design, and tagged anonymous so a user-count chart can exclude it.
    anchor: scope.sessionId
      ? { type: "session-id", value: scope.sessionId }
      : { type: "anonymous", value: scope.anonymousId },
    identity: scope.sessionId
      ? { deviceId: scope.sessionId, resolvedFrom: "explicit" }
      : { deviceId: scope.anonymousId, userId: `anonymous:${scope.anonymousId}`, resolvedFrom: "anchor" },
  });

// The method rides as a property: McpServerContext has no request field, that being tool-scope.
export const createServerEmitter =
  (analytics: Analytics, scope: ServerScope): EmitServerEvent =>
  (method, event, properties) =>
    analytics.trackServerEvent(serverContext(scope), event, { [DIMENSIONS.method]: method, ...properties });

// The SDK's own handshake hook needs a session outliving the request, and this server is stateless.
// Emitted from the initialize request instead, with the SDK's session hooks off (see the config).
export const emitSessionInitialized = (analytics: Analytics, scope: ServerScope, requestedVersion?: string): void => {
  analytics.trackServerEvent(serverContext(scope), EVENTS.sessionInitialized, {
    [DIMENSIONS.requestedProtocolVersion]: requestedVersion,
  });
};

/**
 * Everything one dispatched call says about the arguments it was given.
 *
 * Called on both rejection paths - the one before the handler runs and the one inside it - because a
 * caller getting its arguments wrong is one fact whichever check caught it. Never throws and never
 * awaits: a metric may not sit in front of a tool response, and may not fail one.
 */
export const emitToolSignals = (
  emit: EmitServerEvent,
  name: string,
  args: Record<string, unknown>,
  call: ToolCallResult,
): void => {
  const tool = findTool(name);
  // A name the caller invented is not a tool of ours, and the SDK already reports it under its own
  // Attempted Tool Name. Naming it here would put unbounded input on the tool dimension.
  if (!tool) return;
  try {
    const on = (event: string, properties: Record<string, unknown>) =>
      emit("tools/call", event, { [DIMENSIONS.toolName]: name, ...properties });

    if (call.error !== undefined) {
      const reason = classifyRejection(call.error);
      const argument = rejectedArgument(reason, call.error, tool.def, args);
      on(EVENTS.toolArgumentsRejected, {
        [DIMENSIONS.rejectionReason]: reason,
        // Left off rather than sent empty when our message named nothing we recognise.
        ...(argument === undefined ? {} : { [DIMENSIONS.rejectedArgument]: argument }),
      });
    }
    if (call.upstreamRejection !== undefined) {
      on(EVENTS.upstreamInputRejected, { [DIMENSIONS.upstreamRejection]: call.upstreamRejection });
    }
    // A refusal replaces the payload, so its findings are on the result wrapper rather than inside
    // structuredContent. Both are counted, and a code seen twice is counted once.
    const refused = (call as { blockedBy?: ActionWarning[] }).blockedBy ?? [];
    const emitted = new Set<string>();
    for (const w of [...normalizeWarnings(refused), ...blockingWarnings(call.result)]) {
      if (emitted.has(w.code)) continue;
      emitted.add(w.code);
      on(EVENTS.actionWarning, { [DIMENSIONS.warningCode]: w.code, [DIMENSIONS.warningLevel]: w.level });
    }
  } catch (e: any) {
    console.error("tool signal emit failed:", e?.message ?? e);
  }
};

// The wallet a call is about, from its args (user or sender). Never leaves this module raw.
const walletOf = (args: Record<string, unknown>): string | undefined => {
  const a = args.user ?? args.sender;
  return typeof a === "string" && ADDRESS_RE.test(a) ? a.toLowerCase() : undefined;
};

/**
 * The analytics subject for a call, as an HMAC of the wallet it is about.
 *
 * The wallet is what makes a returning caller countable, and it is also the one directly personal
 * thing this server sees. Keyed hashing keeps the count and drops the address: same wallet, same
 * subject, nothing recoverable at the far end. The key has to be secret or there is no point -
 * addresses are public and enumerable, so a constant anyone can read turns this back into a lookup.
 *
 * The ingestion key is that secret, rather than one of its own. It is already required for any of
 * this to run, so the two can never be configured apart, and rotating it resets every subject at
 * once - which is the deletion path when a wallet is the only identifier involved.
 */
export const subjectOf = async (
  args: Record<string, unknown>,
  env: Env,
): Promise<string | undefined> => {
  const wallet = walletOf(args);
  if (!wallet || !env.AMPLITUDE_API_KEY) return undefined;
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(env.AMPLITUDE_API_KEY),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(wallet));
  return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
};

// The call's shape as analytics dimensions. Undefined keys are left out so they never land on the
// event as empty properties.
export const callDimensions = (args: Record<string, unknown>): Record<string, unknown> => {
  const out: Record<string, unknown> = {};
  if (typeof args.version === "string") out.version = args.version;
  if (typeof args.chainId === "number") out.chain_id = args.chainId;
  if (typeof args.action === "string") out.action = args.action;
  return out;
};
