/**
 * Photon MCP Worker for Cloudflare
 * Auto-generated - do not edit directly
 *
 * Architecture: each photon (the host plus any `@photons` siblings) lives
 * in its own Durable Object class so `this.memory`, `this.emit`,
 * `this.schedule`, and `this.call` work identically to the local daemon.
 * One DO per `instanceName` per photon — see docs/internals/CF-DURABLE-OBJECTS.md.
 */

import { DurableObject } from 'cloudflare:workers';
import { AsyncLocalStorage } from 'node:async_hooks';
import { CronExpressionParser } from 'cron-parser';
__PHOTON_IMPORTS__

interface Env {
  PHOTON: DurableObjectNamespace;
  [key: string]: any;
}

const DEV_MODE = __DEV_MODE__;
const HOST_PHOTON_NAME = '__HOST_PHOTON_NAME__';
const MCP_AUTH_MODE = __MCP_AUTH_MODE__;
const MCP_JWT_ISSUER = __MCP_JWT_ISSUER__;
const MCP_JWT_AUDIENCE = __MCP_JWT_AUDIENCE__;
const MCP_JWT_JWKS = __MCP_JWT_JWKS__;
const DEPLOY_INSTANCE_ALIASES: Record<string, string> = __INSTANCE_ALIASES__;

/**
 * Photon name → Worker env binding name. Generated at deploy time from the
 * host photon's source plus every `@photons` sibling. `this.call('foo.bar')`
 * uses this to find the right DO namespace.
 */
const PHOTON_BINDINGS: Record<string, string> = __PHOTON_BINDINGS_MAP__;

const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance',
};

/**
 * Precompiled .tsx UI manifest (uiId → asset descriptor). Generated at
 * deploy time so `GET /api/ui/<id>` serves the cache-busting shell and
 * the content-hashed bundle from the [assets] binding, matching the
 * local server and Beam contract. Empty when the deploy has no .tsx UI.
 */
const UI_ASSET_MANIFEST: Record<string, { base: string; js: string; hash: string }> =
  __UI_ASSET_MANIFEST__;

/**
 * Photon companion-folder assets embedded at deploy time for synchronous
 * `this.assets(subpath, { load })` parity with the local runtime. Files are
 * keyed by photon name and normalized relative path.
 */
const PHOTON_ASSET_CONTENTS: Record<string, Record<string, string>> = __PHOTON_ASSET_CONTENTS__;

function readConstructorEnv(env: Env, name: string, type: string): unknown {
  const raw = env[name];
  if (raw === undefined || raw === null || raw === '') return undefined;
  const value = String(raw);
  switch (type) {
    case 'boolean':
      return value === 'true' || value === '1';
    case 'number': {
      const parsed = Number(value);
      if (Number.isNaN(parsed)) {
        throw new Error(`Environment variable ${name} must be a number`);
      }
      return parsed;
    }
    default:
      return value;
  }
}

// ════════════════════════════════════════════════════════════════════════════
// Memory proxy — ctx.storage backing for this.memory
// ════════════════════════════════════════════════════════════════════════════

/**
 * Build a `MemoryBackend`-shaped accessor over `ctx.storage`. Mirrors the
 * `this.memory` API the local runtime exposes. Per-namespace keys are
 * encoded as `${namespace}:${key}` so one DO storage table holds multiple
 * namespaces while preserving the boundary the runtime relies on. The DO
 * input gate already serializes calls per-DO, so `update` is just
 * read-modify-write — no extra locking needed.
 */
function createMemoryProxy(ctx: DurableObjectState) {
  const ns = (key: string, namespace = 'default') => `${namespace}:${key}`;
  return {
    async get(key: string, opts?: { namespace?: string }) {
      const v = await ctx.storage.get(ns(key, opts?.namespace));
      return v === undefined ? null : v;
    },
    async set(key: string, value: unknown, opts?: { namespace?: string }) {
      await ctx.storage.put(ns(key, opts?.namespace), value);
    },
    async delete(key: string, opts?: { namespace?: string }) {
      return ctx.storage.delete(ns(key, opts?.namespace));
    },
    async has(key: string, opts?: { namespace?: string }) {
      const v = await ctx.storage.get(ns(key, opts?.namespace));
      return v !== undefined;
    },
    async keys(opts?: { namespace?: string }) {
      const prefix = `${opts?.namespace ?? 'default'}:`;
      const map = await ctx.storage.list({ prefix });
      return Array.from(map.keys()).map((k) => k.slice(prefix.length));
    },
    async list(opts?: { namespace?: string; prefix?: string }) {
      const namespacePrefix = `${opts?.namespace ?? 'default'}:`;
      const prefix = namespacePrefix + (opts?.prefix ?? '');
      const map = await ctx.storage.list({ prefix });
      return Array.from(map.entries()).map(([k, v]) => ({
        key: k.slice(namespacePrefix.length),
        value: v,
      }));
    },
    async clear(opts?: { namespace?: string }) {
      const prefix = `${opts?.namespace ?? 'default'}:`;
      const map = await ctx.storage.list({ prefix });
      if (map.size > 0) {
        await ctx.storage.delete(Array.from(map.keys()));
      }
    },
    async update(
      key: string,
      updater: (current: any) => any,
      opts?: { namespace?: string }
    ): Promise<any> {
      const fullKey = ns(key, opts?.namespace);
      const current = (await ctx.storage.get(fullKey)) ?? null;
      const next = await updater(current);
      await ctx.storage.put(fullKey, next);
      return next;
    },
  };
}

// ════════════════════════════════════════════════════════════════════════════
// Data provider — ctx.storage backing for this.data
// ════════════════════════════════════════════════════════════════════════════

function dataSegment(value: string): string {
  return encodeURIComponent(value).replace(/%/g, '~') || '_';
}

function dataPrefix(photonName: string, scope: string, kind: 'table' | 'log', name: string): string {
  const sessionId = requestContext.getStore()?.sessionId;
  const scoped =
    scope === 'photon'
        ? `photon:${dataSegment(photonName)}`
        : scope === 'session' && sessionId
          ? `session:${dataSegment(sessionId)}:${dataSegment(photonName)}`
          : (() => {
            if (scope === 'global') {
              throw new Error(
                'Global-scoped data on Cloudflare requires a shared global data Durable Object binding'
              );
            }
            throw new Error(
              'Session-scoped data requires Mcp-Session-Id in the Cloudflare request context'
            );
          })();
  return `data:${scoped}:${kind}:${dataSegment(name)}:`;
}

function createDataProvider(ctx: DurableObjectState, photonName: string) {
  const cursorOffset = (cursor: string | number | null | undefined): number => {
    if (cursor === null || cursor === undefined || cursor === '') return 0;
    const parsed = Number(cursor);
    return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
  };
  const safeLimit = (limit: number | undefined, fallback = 100): number => {
    if (limit === undefined) return fallback;
    if (!Number.isFinite(limit) || limit <= 0) return fallback;
    return Math.floor(limit);
  };

  const table = (name: string, scope = 'photon') => ({
    async get(id: string) {
      const envelope = await ctx.storage.get<any>(
        `${dataPrefix(photonName, scope, 'table', name)}${dataSegment(id)}`
      );
      return envelope === undefined ? null : envelope.value;
    },
    async put(id: string, value: unknown) {
      await ctx.storage.put(
        `${dataPrefix(photonName, scope, 'table', name)}${dataSegment(id)}`,
        { id, value, updated_at: new Date().toISOString() }
      );
    },
    async delete(id: string) {
      return ctx.storage.delete(`${dataPrefix(photonName, scope, 'table', name)}${dataSegment(id)}`);
    },
    async has(id: string) {
      return (await ctx.storage.get(`${dataPrefix(photonName, scope, 'table', name)}${dataSegment(id)}`)) !== undefined;
    },
    async list(options: { prefix?: string; limit?: number; cursor?: string | number | null } = {}) {
      const prefix = dataPrefix(photonName, scope, 'table', name);
      const rows = Array.from((await ctx.storage.list<any>({ prefix })).values())
        .filter((envelope) => !options.prefix || envelope.id.startsWith(options.prefix))
        .sort((a, b) => String(a.id).localeCompare(String(b.id)));
      const total = rows.length;
      const offset = cursorOffset(options.cursor);
      const limit = safeLimit(options.limit);
      const page = rows.slice(offset, offset + limit);
      return {
        items: page.map((envelope) => ({
          id: envelope.id,
          value: envelope.value,
          updated_at: envelope.updated_at,
        })),
        next_cursor: offset + page.length < total ? String(offset + page.length) : null,
        total,
      };
    },
    async clear() {
      const keys = Array.from((await ctx.storage.list({ prefix: dataPrefix(photonName, scope, 'table', name) })).keys());
      if (keys.length > 0) await ctx.storage.delete(keys);
    },
  });

  const log = (name: string, scope = 'photon') => {
    const prefix = () => dataPrefix(photonName, scope, 'log', name);
    const metaKey = () => `${prefix()}__meta`;
    const entryKey = (index: number) => `${prefix()}${String(index).padStart(20, '0')}`;
    return {
      async append(value: unknown) {
        const meta = (await ctx.storage.get<{ nextIndex: number }>(metaKey())) ?? { nextIndex: 0 };
        const entry = {
          index: meta.nextIndex,
          value,
          timestamp: new Date().toISOString(),
        };
        await ctx.storage.put(entryKey(entry.index), { value, timestamp: entry.timestamp });
        await ctx.storage.put(metaKey(), { nextIndex: entry.index + 1 });
        return entry;
      },
      async read(options: { after?: string | number; before?: string | number; limit?: number } = {}) {
        const p = prefix();
        const after = options.after === undefined ? -1 : Number(options.after);
        const before = options.before === undefined ? Number.POSITIVE_INFINITY : Number(options.before);
        const entries = Array.from((await ctx.storage.list<any>({ prefix: p })).entries())
          .filter(([key]) => key !== metaKey())
          .map(([key, envelope]) => ({
            index: Number(key.slice(p.length)),
            value: envelope.value,
            timestamp: envelope.timestamp,
          }))
          .filter((entry) => Number.isFinite(entry.index) && entry.index > after && entry.index < before)
          .sort((a, b) => a.index - b.index);
        const total = entries.length;
        const limit = safeLimit(options.limit);
        const page = entries.slice(0, limit);
        return {
          entries: page,
          next_cursor: page.length < total && page.length > 0 ? String(page[page.length - 1].index) : null,
          total,
        };
      },
      async clear() {
        const keys = Array.from((await ctx.storage.list({ prefix: prefix() })).keys());
        if (keys.length > 0) await ctx.storage.delete(keys);
      },
      async delete() {
        await this.clear();
      },
    };
  };

  return {
    table,
    log,
    get sql() {
      const sql = (ctx.storage as any).sql;
      if (!sql) {
        throw new Error(
          'Data SQL is not available on this Durable Object storage; enable SQLite-backed Durable Objects'
        );
      }
      return {
        exec: async (query: string, params: unknown[] = []) => {
          const cursor = sql.exec(query, ...params);
          const rows =
            typeof cursor?.toArray === 'function'
              ? cursor.toArray()
              : Array.isArray(cursor?.rows)
                ? cursor.rows
                : Array.isArray(cursor)
                  ? cursor
                  : [];
          return { rows };
        },
      };
    },
  };
}

// ════════════════════════════════════════════════════════════════════════════
// Schedule provider — DO alarm multiplexer
// ════════════════════════════════════════════════════════════════════════════

const SCHEDULE_PREFIX = '__sched__:';

const CRON_SHORTHANDS: Record<string, string> = {
  '@yearly': '0 0 1 1 *',
  '@annually': '0 0 1 1 *',
  '@monthly': '0 0 1 * *',
  '@weekly': '0 0 * * 0',
  '@daily': '0 0 * * *',
  '@midnight': '0 0 * * *',
  '@hourly': '0 * * * *',
};

function resolveCron(schedule: string): string {
  const trimmed = schedule.trim();
  const shorthand = CRON_SHORTHANDS[trimmed.toLowerCase()];
  if (shorthand) return shorthand;
  if (trimmed.split(/\s+/).length !== 5) {
    throw new Error(
      `Invalid cron expression: '${schedule}'. Expected 5 fields or a shorthand (@hourly, @daily, @weekly, @monthly, @yearly).`
    );
  }
  return trimmed;
}

function nextFireMs(cron: string, from: Date = new Date()): number {
  return CronExpressionParser.parse(cron, { currentDate: from }).next().getTime();
}

interface ScheduledTask {
  id: string;
  name: string;
  description?: string;
  cron: string;
  method: string;
  params: Record<string, unknown>;
  fireOnce: boolean;
  maxExecutions: number;
  status: 'active' | 'paused' | 'completed' | 'error';
  createdAt: string;
  lastExecutionAt?: string;
  executionCount: number;
  errorMessage?: string;
  photonId: string;
}

async function listSchedules(ctx: DurableObjectState): Promise<ScheduledTask[]> {
  const map = await ctx.storage.list<ScheduledTask>({ prefix: SCHEDULE_PREFIX });
  return Array.from(map.values());
}

async function rescheduleAlarm(ctx: DurableObjectState): Promise<void> {
  const tasks = await listSchedules(ctx);
  let earliest = Infinity;
  const now = Date.now();
  for (const task of tasks) {
    if (task.status !== 'active') continue;
    const fromTs = Math.max(
      task.lastExecutionAt ? Date.parse(task.lastExecutionAt) : 0,
      now - 1000
    );
    const t = nextFireMs(task.cron, new Date(fromTs));
    if (t < earliest) earliest = t;
  }
  if (earliest === Infinity) {
    await ctx.storage.deleteAlarm();
  } else {
    await ctx.storage.setAlarm(earliest);
  }
}

function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
  return {
    async create(opts: {
      name: string;
      schedule: string;
      method: string;
      params?: Record<string, unknown>;
      description?: string;
      fireOnce?: boolean;
      maxExecutions?: number;
    }): Promise<ScheduledTask> {
      const cron = resolveCron(opts.schedule);
      const all = await listSchedules(ctx);
      if (all.find((t) => t.name === opts.name)) {
        throw new Error(`Schedule '${opts.name}' already exists. Use update() to modify it.`);
      }
      const task: ScheduledTask = {
        id: crypto.randomUUID(),
        name: opts.name,
        description: opts.description,
        cron,
        method: opts.method,
        params: opts.params ?? {},
        fireOnce: opts.fireOnce ?? false,
        maxExecutions: opts.maxExecutions ?? 0,
        status: 'active',
        createdAt: new Date().toISOString(),
        executionCount: 0,
        photonId,
      };
      await ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
      await rescheduleAlarm(ctx);
      return task;
    },
    async get(id: string): Promise<ScheduledTask | null> {
      return (await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id)) ?? null;
    },
    async getByName(name: string): Promise<ScheduledTask | null> {
      const all = await listSchedules(ctx);
      return all.find((t) => t.name === name) ?? null;
    },
    async list(status?: ScheduledTask['status']): Promise<ScheduledTask[]> {
      const all = await listSchedules(ctx);
      return status ? all.filter((t) => t.status === status) : all;
    },
    async cancel(id: string): Promise<boolean> {
      const ok = await ctx.storage.delete(SCHEDULE_PREFIX + id);
      if (ok) await rescheduleAlarm(ctx);
      return ok;
    },
    async cancelByName(name: string): Promise<boolean> {
      const task = await this.getByName(name);
      if (!task) return false;
      return this.cancel(task.id);
    },
    async has(name: string): Promise<boolean> {
      return (await this.getByName(name)) !== null;
    },
    async update(
      id: string,
      updates: Partial<
        Pick<ScheduledTask, 'method' | 'params' | 'description' | 'fireOnce' | 'maxExecutions'>
      > & { schedule?: string }
    ): Promise<ScheduledTask> {
      const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
      if (!cur) throw new Error(`Schedule ${id} not found`);
      const next: ScheduledTask = {
        ...cur,
        ...updates,
        cron: updates.schedule ? resolveCron(updates.schedule) : cur.cron,
      };
      await ctx.storage.put(SCHEDULE_PREFIX + id, next);
      await rescheduleAlarm(ctx);
      return next;
    },
    async pause(id: string): Promise<ScheduledTask> {
      const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
      if (!cur) throw new Error(`Schedule ${id} not found`);
      if (cur.status !== 'active') {
        throw new Error(`Cannot pause task with status '${cur.status}'. Only active tasks can be paused.`);
      }
      const next: ScheduledTask = { ...cur, status: 'paused' };
      await ctx.storage.put(SCHEDULE_PREFIX + id, next);
      await rescheduleAlarm(ctx);
      return next;
    },
    async resume(id: string): Promise<ScheduledTask> {
      const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
      if (!cur) throw new Error(`Schedule ${id} not found`);
      if (cur.status !== 'paused') {
        throw new Error(`Cannot resume task with status '${cur.status}'. Only paused tasks can be resumed.`);
      }
      const next: ScheduledTask = { ...cur, status: 'active' };
      await ctx.storage.put(SCHEDULE_PREFIX + id, next);
      await rescheduleAlarm(ctx);
      return next;
    },
  };
}

// ════════════════════════════════════════════════════════════════════════════
// Cross-photon call — env.PHOTON_<NAME> stub routing
// ════════════════════════════════════════════════════════════════════════════

/**
 * `this.call('sibling.method', params, {instance?})` — hops to a sibling
 * photon's DO via the env binding generated at deploy time. The sibling DO
 * exposes an internal /__call endpoint that dispatches the request to the
 * named method (with the same simpleParams spreading rule the public MCP
 * surface uses) and returns the result inline.
 */
function createCallProvider(env: Env, callerName: string) {
  return async (
    target: string,
    params: Record<string, unknown> = {},
    options?: { instance?: string }
  ): Promise<unknown> => {
    const dotIndex = target.indexOf('.');
    if (dotIndex === -1) {
      throw new Error(
        `Invalid call target: '${target}'. Expected format: 'photonName.methodName'.`
      );
    }
    const photonName = target.slice(0, dotIndex);
    const methodName = target.slice(dotIndex + 1);
    if (photonName === callerName) {
      throw new Error(
        `this.call('${target}') points at the caller's own photon. Call the method directly via this.${methodName}(...) instead.`
      );
    }
    const bindingName = PHOTON_BINDINGS[photonName];
    if (!bindingName) {
      throw new Error(
        `Unknown photon '${photonName}' in this.call('${target}'). Add it to the host photon's @photons docblock so it gets bundled.`
      );
    }
    const ns = env[bindingName] as DurableObjectNamespace | undefined;
    if (!ns) {
      throw new Error(
        `Worker is missing binding '${bindingName}'. The deploy adapter should have generated it; rerun \`photon host deploy cf\`.`
      );
    }
    const stub = ns.get(ns.idFromName(options?.instance ?? 'default'));
    const res = await stub.fetch('http://photon.internal/__call', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ method: methodName, args: params }),
    });
    const payload = (await res.json()) as { ok: boolean; result?: unknown; error?: string };
    if (!payload.ok) {
      throw new Error(payload.error ?? `Cross-photon call to ${target} failed`);
    }
    return payload.result;
  };
}

/**
 * Build a `Cloudflare` adapter for the deployed Worker. Each scoped
 * category resolves to `env[bindingNameFor(photon, category, qualifier)]`
 * — the same auto-naming convention the local miniflare sandbox seeds,
 * so photon source is unchanged across runtimes. Shared categories
 * (ai/images/browser) read fixed canonical env keys.
 *
 * Mirrors photon-core's `createCloudflareFromEnv` line-for-line. We
 * inline it rather than import to keep the template self-contained
 * (the bundled Worker shouldn't depend on `@portel/photon-core` at
 * runtime — too much surface for what's needed here).
 */
function bindingNameFor(photon: string, category: string, qualifier?: string): string {
  const safePhoton = photon.toLowerCase().replace(/-/g, '_');
  if (qualifier && qualifier.length > 0) {
    const safeQualifier = qualifier.toLowerCase().replace(/-/g, '_');
    return `${safePhoton}_${safeQualifier}_${category}`;
  }
  return `${safePhoton}_${category}`;
}

function createCloudflareFromEnv(env: Env, photonName: string): any {
  const scoped = (category: string) => (qualifier?: string) => {
    const name = bindingNameFor(photonName, category, qualifier);
    const binding = (env as any)[name];
    if (!binding) {
      throw new Error(
        `cf.${category}(${qualifier ? JSON.stringify(qualifier) : ''}) requires ` +
          `binding "${name}" on the Worker env, but it is not defined. Add it to ` +
          `wrangler.toml (or run \`photon host deploy cloudflare\` to regenerate ` +
          `bindings from the photon's source).`
      );
    }
    return binding;
  };
  return {
    kv: scoped('kv'),
    r2: scoped('r2'),
    d1: scoped('d1'),
    queue: scoped('queue'),
    vectorize: scoped('vectorize'),
    ai: (env as any).AI,
    images: (env as any).IMAGES,
    browser: (env as any).BROWSER,
    fetch: (input: any, init?: any) => fetch(input, init),
  };
}

// ════════════════════════════════════════════════════════════════════════════
// Capability shim — wires this.* on the photon instance
// ════════════════════════════════════════════════════════════════════════════

function withCfCapabilities(
  instance: any,
  ctx: DurableObjectState,
  env: Env,
  photonName: string
): any {
  Object.defineProperty(instance, 'memory', {
    value: createMemoryProxy(ctx),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'data', {
    value: createDataProvider(ctx, photonName),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'emit', {
    value: (event: { emit?: string; [k: string]: unknown }) => {
      // SSE forwarding for in-flight tool calls. When an SSE
      // request context is active (i.e. the caller used
      // `Accept: text/event-stream`), `this.emit({ emit: 'progress' })`,
      // `this.emit({ emit: 'status' })`, etc. become real
      // `notifications/progress` / `notifications/message` JSON-RPC
      // notifications written to the open stream — the client sees
      // them arrive as the tool runs, not in a single batch at the
      // end. Mirrors `src/server.ts:1340-1397` for runtime parity
      // between local and deployed transports.
      const reqCtx = requestContext.getStore();
      if (!reqCtx) return;
      const kind = (event && typeof event === 'object' ? event.emit : undefined) as string | undefined;
      if (!kind) return;
      const progressToken = reqCtx.progressToken;
      if (kind === 'progress') {
        const rawValue = typeof event.value === 'number' ? (event.value as number) : 0;
        const progress = rawValue <= 1 ? rawValue * 100 : rawValue;
        void reqCtx.send({
          jsonrpc: '2.0',
          method: 'notifications/progress',
          params: {
            ...(progressToken !== undefined ? { progressToken } : {}),
            progress,
            total: 100,
            ...(typeof event.message === 'string' ? { message: event.message } : {}),
          },
        });
      } else if (kind === 'status') {
        void reqCtx.send({
          jsonrpc: '2.0',
          method: 'notifications/progress',
          params: {
            ...(progressToken !== undefined ? { progressToken } : {}),
            progress: 0,
            total: 100,
            message: typeof event.message === 'string' ? event.message : '',
          },
        });
      } else if (kind === 'log') {
        void reqCtx.send({
          jsonrpc: '2.0',
          method: 'notifications/message',
          params: {
            level: typeof event.level === 'string' ? event.level : 'info',
            data: typeof event.message === 'string' ? event.message : '',
          },
        });
      } else if (kind === 'render') {
        void reqCtx.send({
          jsonrpc: '2.0',
          method: 'notifications/message',
          params: {
            level: 'info',
            data: JSON.stringify({
              _render: true,
              format: event.format,
              value: event.value,
            }),
          },
        });
      } else if (kind === 'render:clear') {
        void reqCtx.send({
          jsonrpc: '2.0',
          method: 'notifications/message',
          params: {
            level: 'info',
            data: JSON.stringify({ _render: true, clear: true }),
          },
        });
      }
    },
    writable: false,
    enumerable: false,
    configurable: false,
  });

  // Emit-based convenience helpers — `this.status(...)`, `this.log(...)`,
  // `this.progress(...)`, `this.toast(...)`, `this.thinking(...)`,
  // `this.render(...)`. The classic loader injects these on every
  // plain-class instance (`src/loader.ts:injectEmitHelpers`); the
  // deployed Worker must do the same so a photon written against the
  // local runtime behaves identically when served from CF. Without
  // these, calls like `this.status?.('one')` short-circuit silently
  // on the Worker and the whole chain of progress notifications is
  // lost — which is exactly the symptom that prompted this wiring.
  // Each helper guards on `in instance` so user-declared methods on
  // the photon class always win.
  const emitFn = (data: { [k: string]: unknown }) =>
    (instance as { emit: (d: unknown) => void }).emit(data);
  if (!('render' in instance)) {
    (instance as { render?: (format?: string, value?: unknown) => void }).render = (
      format?: string,
      value?: unknown,
    ) => {
      if (format === undefined) return emitFn({ emit: 'render:clear' });
      if (format === 'status') {
        return emitFn(
          typeof value === 'string'
            ? { emit: 'status', message: value }
            : { emit: 'status', ...(value as object) }
        );
      }
      if (format === 'progress') {
        return emitFn(
          typeof value === 'number'
            ? { emit: 'progress', value }
            : { emit: 'progress', ...(value as object) }
        );
      }
      if (format === 'toast') {
        return emitFn(
          typeof value === 'string'
            ? { emit: 'toast', message: value }
            : { emit: 'toast', ...(value as object) }
        );
      }
      emitFn({ emit: 'render', format, value });
    };
  }
  if (!('toast' in instance)) {
    (instance as { toast?: (m: string, o?: { type?: string; duration?: number }) => void }).toast = (
      message: string,
      opts: { type?: string; duration?: number } = {},
    ) => emitFn({ emit: 'toast', message, ...opts });
  }
  if (!('log' in instance)) {
    (instance as { log?: (m: string, o?: { level?: string; data?: unknown }) => void }).log = (
      message: string,
      opts: { level?: string; data?: unknown } = {},
    ) => emitFn({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
  }
  if (!('status' in instance)) {
    // Signature mirrors `src/loader.ts:288` exactly. Photons that pass a
    // second arg (e.g. `this.status('one', { n: 1 })`) keep working —
    // the extra arg is dropped on both transports for parity. If we
    // ever decide to surface it, change both call sites at once.
    (instance as { status?: (m: string) => void }).status = (message: string) =>
      emitFn({ emit: 'status', message });
  }
  if (!('progress' in instance)) {
    (instance as { progress?: (v: number, m?: string) => void }).progress = (
      value: number,
      message?: string,
    ) => emitFn({ emit: 'progress', value, message });
  }
  if (!('thinking' in instance)) {
    (instance as { thinking?: (a?: boolean) => void }).thinking = (active = true) =>
      emitFn({ emit: 'thinking', active });
  }

  Object.defineProperty(instance, 'schedule', {
    value: createScheduleProvider(ctx, photonName),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'call', {
    value: createCallProvider(env, photonName),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'assets', {
    value: createAssetsProvider(photonName),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'callerCwd', {
    get() {
      return undefined;
    },
    enumerable: false,
    configurable: false,
  });

  Object.defineProperty(instance, 'caller', {
    get() {
      return (
        mcpAuthContext.getStore()?.caller ?? {
          id: 'anonymous',
          name: undefined,
          anonymous: true,
          scope: undefined,
          claims: {},
        }
      );
    },
    enumerable: false,
    configurable: false,
  });

  // Worker env exposed to the photon for direct binding access — Workers AI
  // (`env.AI.run('@cf/...', ...)`), KV, R2, queues, secrets. Photons that
  // want to stay CF-portable should branch on `(this as any).env?.AI` and
  // fall back to a non-CF path on the local daemon (where `env` is
  // undefined). Non-enumerable so it doesn't leak into JSON output.
  Object.defineProperty(instance, 'env', {
    value: env,
    writable: false,
    enumerable: false,
    configurable: false,
  });

  // `this.mcpAuthed` — true when the active /mcp request passed the
  // PHOTON_MCP_BEARER check, false when no secret is configured or the
  // request hit a path outside MCP dispatch (e.g. /api/* or /__call).
  // User code can guard sensitive methods with:
  //   if (!this.mcpAuthed) throw new Error('unauthorized');
  // The flag is per-tool-call (AsyncLocalStorage scoped); it doesn't
  // persist across awaits that escape the dispatch context.
  Object.defineProperty(instance, 'mcpAuthed', {
    get() {
      return mcpAuthContext.getStore()?.authed === true;
    },
    enumerable: false,
    configurable: false,
  });

  // `this.cf` — the wrapped Cloudflare surface. Auto-naming
  // (`bindingNameFor`) resolves `cf.kv()` to `<photon>_kv`,
  // `cf.kv('cache')` to `<photon>_cache_kv`, etc. The deploy
  // pipeline emits matching wrangler.toml entries from the same
  // convention, so bindings always line up across runtimes.
  Object.defineProperty(instance, 'cf', {
    value: createCloudflareFromEnv(env, photonName),
    writable: false,
    enumerable: false,
    configurable: false,
  });

  // Human/LLM-in-the-loop primitives. Each one wraps an MCP server-initiated
  // request (sampling/createMessage, elicitation/create), pushed over the
  // active tool call's SSE response stream and awaited on a Promise keyed by
  // request id. The pending map and the SSE writer live on the per-request
  // AsyncLocalStorage context so concurrent tool calls don't collide.
  Object.defineProperty(instance, 'sample', {
    value: cfSample,
    writable: false,
    enumerable: false,
    configurable: false,
  });
  Object.defineProperty(instance, 'confirm', {
    value: cfConfirm,
    writable: false,
    enumerable: false,
    configurable: false,
  });
  Object.defineProperty(instance, 'elicit', {
    value: cfElicit,
    writable: false,
    enumerable: false,
    configurable: false,
  });

  return instance;
}

function createAssetsProvider(photonName: string) {
  return (subpath: string, options?: boolean | { load?: boolean; encoding?: string | null }) => {
    const normalized = typeof options === 'boolean' ? { load: options } : (options ?? {});
    const assetPath = normalizeAssetPath(subpath);
    if (!normalized.load) return `/${photonName}/${assetPath}`;

    const encoded = PHOTON_ASSET_CONTENTS[photonName]?.[assetPath];
    if (encoded === undefined) {
      throw new Error(`Asset not found: ${assetPath}`);
    }

    const bytes = base64ToBytes(encoded);
    if (normalized.encoding === null) return bytes;
    const encoding = !normalized.encoding || normalized.encoding === 'utf8' ? 'utf-8' : normalized.encoding;
    return new TextDecoder(encoding).decode(bytes);
  };
}

function normalizeAssetPath(subpath: string): string {
  const clean = String(subpath)
    .replace(/\\/g, '/')
    .replace(/^\/+/, '')
    .split('/')
    .filter((part) => part && part !== '.');
  if (clean.some((part) => part === '..')) {
    throw new Error(`Invalid asset path: ${subpath}`);
  }
  return clean.join('/');
}

function base64ToBytes(value: string): Uint8Array {
  const binary = atob(value);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

// ════════════════════════════════════════════════════════════════════════════
// Server-initiated MCP requests — sample / confirm / elicit
// ════════════════════════════════════════════════════════════════════════════

interface PendingRequest {
  resolve: (value: any) => void;
  reject: (error: Error) => void;
}

interface RequestContext {
  /** Push one JSON-RPC message to the client over the active SSE response. */
  send: (msg: unknown) => Promise<void>;
  /** Shared pending map; the DO's POST /mcp handler resolves entries here. */
  pendingRequests: Map<string, PendingRequest>;
  /**
   * Client-supplied progress token from the originating request's
   * `_meta.progressToken`. Echoed back in every `notifications/progress`
   * event we send during the call so the client can correlate progress
   * with its outstanding request. Falls back to a synthesized token if
   * the client didn't supply one.
   */
  progressToken?: string | number;
  /** Client session id from Mcp-Session-Id, used for session-scoped data. */
  sessionId?: string;
}

/**
 * Per-tool-call context. Set when the DO begins streaming a tool call
 * response and read by `this.sample` / `this.confirm` / `this.elicit` to
 * find the right SSE writer + pending map. AsyncLocalStorage propagates
 * the context across awaits so a tool that calls `await this.sample()`
 * deep in its async tree still hits the right context.
 */
const requestContext = new AsyncLocalStorage<RequestContext>();

/**
 * Per-tool-call MCP auth context. Set when the bearer check on `/mcp`
 * has passed (or skipped because no `PHOTON_MCP_BEARER` secret is
 * configured) so user code reading `this.mcpAuthed` sees the right
 * value for the dispatched method. The flag is scoped to the single
 * `tools/call` invocation, not the DO lifetime.
 */
const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean; caller?: any }>();

/**
 * Constant-time string comparison to avoid leaking the bearer through
 * timing differences. Falls back to a manual byte loop because Workers
 * doesn't always expose `crypto.timingSafeEqual`.
 */
function timingSafeEqualString(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let mismatch = 0;
  for (let i = 0; i < a.length; i++) {
    mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return mismatch === 0;
}

/**
 * Validate the Authorization header against `env.PHOTON_MCP_BEARER`.
 * Returns:
 *  - `{ enforced: false }` when no secret is configured (back-compat).
 *  - `{ enforced: true, ok: true }` when the bearer matches.
 *  - `{ enforced: true, ok: false, reason }` when missing or wrong.
 *
 * Methods that don't dispatch user code (`tools/list`, `initialize`,
 * `notifications/*`, `ping`) bypass this check at the call site.
 */
function checkMcpBearer(
  request: Request,
  env: Env
): { enforced: false } | { enforced: true; ok: boolean; reason?: string } {
  const expected = (env as Record<string, unknown>).PHOTON_MCP_BEARER;
  if (typeof expected !== 'string' || expected.length === 0) {
    return { enforced: false };
  }
  const header = request.headers.get('Authorization') ?? '';
  const match = header.match(/^Bearer\s+(.+)$/i);
  if (!match) {
    return { enforced: true, ok: false, reason: 'Authorization: Bearer <token> header missing' };
  }
  const presented = match[1].trim();
  if (!timingSafeEqualString(presented, expected)) {
    return { enforced: true, ok: false, reason: 'bearer token does not match PHOTON_MCP_BEARER' };
  }
  return { enforced: true, ok: true };
}

type McpAuthResult =
  | { enforced: false; ok: true; authed: false }
  | { enforced: true; ok: true; authed: true; caller?: any }
  | { enforced: true; ok: false; status: number; code: number; message: string; reason: string; wwwAuthenticate: string };

function bearerTokenFromRequest(request: Request): string | null {
  const header = request.headers.get('Authorization') ?? '';
  const match = header.match(/^Bearer\s+(.+)$/i);
  return match ? match[1].trim() : null;
}

function base64UrlDecode(input: string): string {
  const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
  const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
  return atob(padded);
}

function base64UrlBytes(input: string): Uint8Array {
  const binary = base64UrlDecode(input);
  const out = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
  return out;
}

function parseJwtPart(part: string): any | null {
  try {
    return JSON.parse(base64UrlDecode(part));
  } catch {
    return null;
  }
}

async function verifyMcpJwt(
  request: Request,
  requiredScopes: string[] = []
): Promise<McpAuthResult> {
  if (MCP_AUTH_MODE !== 'jwt') {
    return { enforced: false, ok: true, authed: false };
  }
  if (!MCP_JWT_ISSUER || !MCP_JWT_AUDIENCE || !MCP_JWT_JWKS?.keys?.length) {
    return jwtReject('missing_token');
  }
  const token = bearerTokenFromRequest(request);
  if (!token) return jwtReject('missing_token');
  const parts = token.split('.');
  if (parts.length !== 3) return jwtReject('malformed_token');
  const header = parseJwtPart(parts[0]);
  const claims = parseJwtPart(parts[1]);
  if (!header || !claims) return jwtReject('malformed_token');
  if (header.alg !== 'ES256') return jwtReject('unsupported_alg');
  const kid = typeof header.kid === 'string' ? header.kid : '';
  const jwk = MCP_JWT_JWKS.keys.find((key: any) => key?.kid === kid);
  if (!kid || !jwk) return jwtReject('unknown_kid');

  let verified = false;
  try {
    const key = await crypto.subtle.importKey(
      'jwk',
      jwk,
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['verify']
    );
    verified = await crypto.subtle.verify(
      { name: 'ECDSA', hash: 'SHA-256' },
      key,
      base64UrlBytes(parts[2]),
      new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
    );
  } catch {
    verified = false;
  }
  if (!verified) return jwtReject('bad_signature');
  if (claims.iss !== MCP_JWT_ISSUER) return jwtReject('wrong_issuer');
  const audMatches = Array.isArray(claims.aud)
    ? claims.aud.includes(MCP_JWT_AUDIENCE)
    : claims.aud === MCP_JWT_AUDIENCE;
  if (!audMatches) return jwtReject('wrong_audience');
  const now = Math.floor(Date.now() / 1000);
  const skew = 60;
  if (typeof claims.exp !== 'number' || claims.exp < now - skew) return jwtReject('expired_token');
  if (typeof claims.nbf === 'number' && claims.nbf > now + skew) {
    return jwtReject('token_not_yet_valid');
  }
  if (typeof claims.iat === 'number' && claims.iat > now + skew) {
    return jwtReject('token_not_yet_valid');
  }
  const granted = new Set(String(claims.scope ?? '').split(/\s+/).filter(Boolean));
  const missing = requiredScopes.find((scope) => !granted.has(scope));
  if (missing) return jwtReject('insufficient_scope', requiredScopes);
  return {
    enforced: true,
    ok: true,
    authed: true,
    caller: {
      id: String(claims.sub ?? 'unknown'),
      name: typeof claims.name === 'string' ? claims.name : undefined,
      anonymous: false,
      scope: typeof claims.scope === 'string' ? claims.scope : undefined,
      claims,
    },
  };
}

function jwtReject(reason: string, scopes: string[] = []): McpAuthResult {
  const insufficient = reason === 'insufficient_scope';
  const scopePart = insufficient && scopes.length > 0 ? `, scope="${scopes.join(' ')}"` : '';
  return {
    enforced: true,
    ok: false,
    status: insufficient ? 403 : 401,
    code: insufficient ? -32003 : -32001,
    message: insufficient ? 'Forbidden' : 'Unauthorized',
    reason,
    wwwAuthenticate: `Bearer realm="photon", error="${insufficient ? 'insufficient_scope' : 'invalid_token'}"${scopePart}`,
  };
}

async function checkMcpAuth(
  request: Request,
  env: Env,
  method: string,
  toolDefinitions: any[],
  body: any
): Promise<McpAuthResult> {
  const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
  if (!requiresAuth) return { enforced: false, ok: true, authed: false };
  if (MCP_AUTH_MODE === 'jwt') {
    const toolName = body?.method === 'tools/call' ? body?.params?.name : undefined;
    const tool = toolDefinitions.find((t: any) => t.name === toolName);
    return verifyMcpJwt(request, Array.isArray(tool?.scopes) ? tool.scopes : []);
  }
  if (MCP_AUTH_MODE === 'open') return { enforced: false, ok: true, authed: false };
  const bearer = checkMcpBearer(request, env);
  if (!bearer.enforced) return { enforced: false, ok: true, authed: false };
  if (bearer.ok) return { enforced: true, ok: true, authed: true };
  return {
    enforced: true,
    ok: false,
    status: 401,
    code: -32001,
    message: 'Unauthorized',
    reason: bearer.reason ?? 'Authorization: Bearer <token> header missing',
    wwwAuthenticate: 'Bearer realm="photon"',
  };
}

/**
 * Methods that may pass through `/mcp` without a bearer because they
 * don't dispatch into user code. `tools/list` advertises the catalog,
 * which is generally safe to expose; if a deployment wants to gate it
 * the photon owner can set CF Access on the route.
 */
const MCP_METHODS_BYPASSING_BEARER = new Set([
  'initialize',
  'notifications/initialized',
  'notifications/cancelled',
  'ping',
  'tools/list',
]);

function requireRequestContext(which: string): RequestContext {
  const ctx = requestContext.getStore();
  if (!ctx) {
    throw new Error(
      `this.${which}() requires the calling client to use SSE (Accept: text/event-stream). ` +
        `Stateless JSON requests can't carry server-initiated MCP messages back to the client. ` +
        `If you control the client, set the Accept header. If not, the photon should not call ` +
        `this.${which}() in tools invoked by JSON-only callers.`
    );
  }
  return ctx;
}

/**
 * Send a server-initiated JSON-RPC request to the client over the active
 * SSE stream and await the matching response. The DO's POST /mcp handler
 * resolves the pending Promise when the client posts back a response with
 * the same id.
 */
async function sendServerRequest(method: string, params: unknown, which: string): Promise<unknown> {
  const ctx = requireRequestContext(which);
  const id = crypto.randomUUID();
  const promise = new Promise<unknown>((resolve, reject) => {
    ctx.pendingRequests.set(id, { resolve, reject });
  });
  await ctx.send({ jsonrpc: '2.0', id, method, params });
  return promise;
}

async function cfSample(params: {
  prompt?: string;
  messages?: Array<{ role: string; content: { type: string; text?: string } }>;
  maxTokens?: number;
  systemPrompt?: string;
  temperature?: number;
}): Promise<string> {
  if (!params.prompt && !params.messages?.length) {
    throw new Error('this.sample() requires either `prompt` or `messages`.');
  }
  const messages =
    params.messages ??
    [{ role: 'user' as const, content: { type: 'text' as const, text: params.prompt! } }];
  const result = (await sendServerRequest(
    'sampling/createMessage',
    {
      messages,
      maxTokens: params.maxTokens ?? 1024,
      ...(params.systemPrompt ? { systemPrompt: params.systemPrompt } : {}),
      ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
    },
    'sample'
  )) as { content?: { type?: string; text?: string } } | string | undefined;
  if (typeof result === 'string') return result;
  if (result?.content?.type === 'text' && typeof result.content.text === 'string') {
    return result.content.text;
  }
  return JSON.stringify(result);
}

async function cfElicit<T = unknown>(params: {
  message?: string;
  question?: string;
  requestedSchema?: unknown;
  [k: string]: unknown;
}): Promise<T> {
  const message = params.message ?? params.question ?? 'Input requested';
  const result = await sendServerRequest(
    'elicitation/create',
    {
      message,
      ...(params.requestedSchema ? { requestedSchema: params.requestedSchema } : {}),
    },
    'elicit'
  );
  // Per MCP elicitation spec the response carries `content` with the user's
  // answer. Some clients return the answer at the top level for simple
  // schemas; accept either shape so photon code stays compact.
  const obj = result as { content?: T } | T;
  return ((obj as { content?: T })?.content ?? (obj as T));
}

async function cfConfirm(question: string): Promise<boolean> {
  const result = await cfElicit<{ confirmed?: boolean } | boolean>({
    message: question,
    requestedSchema: {
      type: 'object',
      properties: {
        confirmed: {
          type: 'boolean',
          title: 'Confirm',
          description: question,
        },
      },
      required: ['confirmed'],
    },
  });
  if (typeof result === 'boolean') return result;
  return Boolean(result?.confirmed);
}

// ════════════════════════════════════════════════════════════════════════════
// Tool dispatch
// ════════════════════════════════════════════════════════════════════════════

/**
 * Convert the JSON-RPC `arguments` object into the positional-call arg list
 * the photon method expects. Mirrors the local loader's logic so a photon
 * behaves identically whether it runs on the daemon or as a Cloudflare Worker.
 */
function spreadArgs(toolDef: any, args: Record<string, unknown>): unknown[] {
  if (toolDef?.simpleParams && args && typeof args === 'object') {
    const paramNames = Object.keys(toolDef.inputSchema?.properties || {});
    return paramNames.map((name) => args[name]);
  }
  return [args];
}

/**
 * Match a request against the `@get`/`@post` route table. Two passes:
 * exact paths first (cheaper, also gives them precedence over patterns
 * that would also match), then `:param` patterns. Returns the matched
 * route plus any extracted path params, or null when nothing matches.
 *
 * Patterns: `/b/:token` matches `/b/abc123` -> { token: 'abc123' }.
 * Segment counts must be equal -- `/b/:token` does not match `/b/x/y`.
 *
 * Tested in tests/cf-template-route-matcher.test.ts. Keep the two
 * definitions in sync if either is edited.
 */
function matchHttpRoute(
  routes: { method: string; path: string; handler: string }[],
  method: string,
  pathname: string
): {
  route: { method: string; path: string; handler: string };
  params: Record<string, string>;
} | null {
  for (const route of routes) {
    if (route.method !== method) continue;
    if (!route.path.includes(':') && route.path === pathname) {
      return { route, params: {} };
    }
  }
  for (const route of routes) {
    if (route.method !== method) continue;
    if (!route.path.includes(':')) continue;
    const params = matchPathPattern(route.path, pathname);
    if (params) return { route, params };
  }
  return null;
}

/**
 * camelCase → kebab-case for `/api/<kebab>` route segments.
 * Mirrors `src/shared/expose-route-extractor.ts#methodToKebab`. Keep both
 * implementations in lock-step so an `@expose`'d method binds to the
 * same path on the local server and on Cloudflare.
 */
function methodToKebab(name: string): string {
  return name
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
    .toLowerCase();
}

function matchPathPattern(
  pattern: string,
  pathname: string
): Record<string, string> | null {
  const patternParts = pattern.split('/').filter(Boolean);
  const pathParts = pathname.split('/').filter(Boolean);
  if (patternParts.length !== pathParts.length) return null;
  const params: Record<string, string> = {};
  for (let i = 0; i < patternParts.length; i++) {
    const pp = patternParts[i];
    const rp = pathParts[i];
    if (pp.startsWith(':')) {
      try {
        params[pp.slice(1)] = decodeURIComponent(rp);
      } catch {
        return null;
      }
    } else if (pp !== rp) {
      return null;
    }
  }
  return params;
}

async function handleMCPRequest(
  request: any,
  photon: any,
  photonName: string,
  toolDefinitions: any[]
): Promise<any> {
  const { method, params, id } = request;

  switch (method) {
    case 'initialize':
      return {
        jsonrpc: '2.0',
        id,
        result: {
          protocolVersion: '2024-11-05',
          capabilities: { tools: {} },
          serverInfo: { name: photonName, version: '1.0.0' },
        },
      };

    case 'tools/list':
      return {
        jsonrpc: '2.0',
        id,
        result: { tools: toolDefinitions },
      };

    case 'tools/call': {
      const { name, arguments: args } = params;
      try {
        const fn = (photon as any)[name];
        if (typeof fn !== 'function') {
          throw new Error(`Unknown tool: ${name}`);
        }
        const toolDef = toolDefinitions.find((t: any) => t.name === name);
        const callArgs = spreadArgs(toolDef, args || {});
        const result = await fn.call(photon, ...callArgs);
        const isString = typeof result === 'string';
        const text = isString ? result : JSON.stringify(result, null, 2);
        const isObject = result !== null && typeof result === 'object';
        return {
          jsonrpc: '2.0',
          id,
          result: {
            content: [{ type: 'text', text }],
            ...(isObject && { structuredContent: result }),
          },
        };
      } catch (error: any) {
        return {
          jsonrpc: '2.0',
          id,
          result: {
            content: [{ type: 'text', text: `Error: ${error.message}` }],
            isError: true,
          },
        };
      }
    }

    case 'notifications/initialized':
    case 'ping':
      return { jsonrpc: '2.0', id, result: {} };

    default:
      return {
        jsonrpc: '2.0',
        id,
        error: { code: -32601, message: `Method not found: ${method}` },
      };
  }
}

async function handleStreamableMCP(
  request: Request,
  photon: any,
  photonName: string,
  toolDefinitions: any[]
): Promise<Response> {
  let body: unknown;
  try {
    body = await request.json();
  } catch (error: any) {
    return Response.json(
      {
        jsonrpc: '2.0',
        id: null,
        error: { code: -32700, message: `Parse error: ${error?.message ?? String(error)}` },
      },
      { status: 400, headers: CORS_HEADERS }
    );
  }
  const result = await handleMCPRequest(body, photon, photonName, toolDefinitions);
  return Response.json(result, { headers: CORS_HEADERS });
}

// ════════════════════════════════════════════════════════════════════════════
// BasePhotonDO — shared logic for every per-photon DO class
// ════════════════════════════════════════════════════════════════════════════

abstract class BasePhotonDO extends DurableObject<Env> {
  protected abstract readonly photonName: string;
  protected abstract readonly toolDefinitions: any[];
  protected readonly httpRoutes: { method: string; path: string; handler: string }[] = [];
  /**
   * Methods declared with `@expose` (Track C). Bound to `POST /api/<kebab>`
   * with a SameSite-style visibility check. `private` requires a browser-set
   * `Sec-Fetch-Site: same-origin` (or `same-site`) header — anything else,
   * or no header, is denied. `public` skips the check (anonymous third-party
   * callers like RSS readers).
   */
  protected readonly exposes: { handler: string; visibility: 'private' | 'public' }[] = [];
  protected abstract createPhoton(env: Env): any;

  protected photon: any;
  protected instanceName: string;

  /**
   * In-flight server-initiated MCP requests, keyed by request id. The active
   * tool call's `this.sample` / `this.confirm` / `this.elicit` add entries
   * here; the POST /mcp handler resolves them when the client sends the
   * response. Lives on the DO instance so it persists across the multiple
   * fetch handlers (one for the original tool call, one per client response).
   */
  protected pendingRequests = new Map<string, PendingRequest>();

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.instanceName = ctx.id.name ?? 'default';
  }

  protected getPhoton(): any {
    if (!this.photon) {
      this.photon = withCfCapabilities(this.createPhoton(this.env), this.ctx, this.env, this.photonName);
    }
    return this.photon;
  }

  async fetch(request: Request): Promise<Response> {
    this.getPhoton();
    const url = new URL(request.url);

    // Internal cross-photon call — invoked by sibling DOs via this.call.
    // Not exposed externally (outer Worker doesn't route to this path).
    if (url.pathname === '/__call' && request.method === 'POST') {
      try {
        const { method, args } = (await request.json()) as {
          method: string;
          args: Record<string, unknown>;
        };
        const fn = (this.photon as any)[method];
        if (typeof fn !== 'function') {
          return Response.json({ ok: false, error: `Unknown method: ${method}` });
        }
        const toolDef = this.toolDefinitions.find((t: any) => t.name === method);
        const callArgs = spreadArgs(toolDef, args || {});
        const result = await fn.call(this.photon, ...callArgs);
        return Response.json({ ok: true, result });
      } catch (error: any) {
        return Response.json({ ok: false, error: error?.message ?? String(error) });
      }
    }

    // Hibernatable WebSocket upgrade for emit subscribers, tagged by channel.
    if (url.pathname === '/events' && request.headers.get('Upgrade') === 'websocket') {
      const channel = url.searchParams.get('channel') ?? 'default';
      const pair = new WebSocketPair();
      const client = pair[0];
      const server = pair[1];
      this.ctx.acceptWebSocket(server, [channel]);
      return new Response(null, { status: 101, webSocket: client });
    }

    // MCP Streamable HTTP. Three flavors of POST /mcp body:
    //  - JSON-RPC request (has `method`): tools/call, initialize, etc.
    //    Tool calls may produce server-initiated requests during execution
    //    (sample/confirm/elicit), so when the client signals SSE support
    //    (`Accept: text/event-stream`) we stream the response. Plain JSON
    //    is the default for clients that don't.
    //  - JSON-RPC response (has `result` or `error`, no `method`): the
    //    client answering a server-initiated request. We resolve the
    //    matching pending entry and ack with 204.
    if (url.pathname === '/mcp' && request.method === 'POST') {
      return this._handleMcpPost(request);
    }
    if (url.pathname === '/mcp' && request.method === 'GET') {
      return new Response(': streamable-http\n\n', {
        headers: {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          ...CORS_HEADERS,
        },
      });
    }
    if (url.pathname === '/mcp' && request.method === 'DELETE') {
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    // Info endpoint with content negotiation. A photon may declare
    // `@get /` to serve a public HTML homepage; in that case JSON
    // requests still get the discovery payload and everything else
    // (browsers, default curl, anything not asking for JSON) falls
    // through to the photon's handler in the route table below.
    if (url.pathname === '/' && request.method === 'GET') {
      const accept = (request.headers.get('accept') || '').toLowerCase();
      const wantsJson = accept.includes('application/json');
      const userHomeRoute = this.httpRoutes.find(
        (r) => r.method === 'GET' && r.path === '/'
      );
      if (wantsJson || !userHomeRoute) {
        return Response.json(
          {
            name: this.photonName,
            instance: this.instanceName,
            transport: 'streamable-http',
            runtime: 'cloudflare-workers',
            endpoints: {
              mcp: '/mcp',
              events: '/events?channel=<name>',
              ...(DEV_MODE ? { playground: '/playground' } : {}),
            },
            tools: this.toolDefinitions.length,
          },
          { headers: CORS_HEADERS }
        );
      }
      // Fall through to the @get/@post route table (handles `@get /`).
    }

    // Dev-only endpoints
    if (DEV_MODE) {
      if (url.pathname === '/playground') {
        return new Response(getPlaygroundHTML(this.photonName, this.toolDefinitions), {
          headers: { 'Content-Type': 'text/html' },
        });
      }
    }

    // @get / @post HTTP routes — dispatch to photon method, bypass MCP.
    // Handler signature: (request: Request, params?: Record<string, string>)
    // — the second arg carries `:param` values extracted from the path so a
    // route declared `@get /b/:token` receives `{ token }`.
    const matchedRoute = matchHttpRoute(this.httpRoutes, request.method, url.pathname);
    if (matchedRoute) {
      const fn = (this.photon as any)[matchedRoute.route.handler];
      if (typeof fn === 'function') {
        try {
          const response = await fn.call(this.photon, request, matchedRoute.params);
          if (response instanceof Response) return response;
          return Response.json(response, { headers: CORS_HEADERS });
        } catch (error: any) {
          return new Response(error?.message ?? 'Internal Server Error', { status: 500 });
        }
      }
    }

    // Track C: @expose auto-RPC. POST /api/<kebab> dispatches to an
    // `@expose`'d method. `@get`/`@post` already won the matchedRoute
    // check above, so this only fires when no explicit HTTP route
    // matched. Visibility gate: `private` requires a browser-set
    // `Sec-Fetch-Site: same-origin` (or `same-site`) header. The Worker
    // is on the public internet — there is no localhost analog — so an
    // absent header means deny.
    if (
      this.exposes.length > 0 &&
      request.method === 'POST' &&
      url.pathname.startsWith('/api/')
    ) {
      const segment = url.pathname.slice('/api/'.length);
      const exposed = this.exposes.find((e) => methodToKebab(e.handler) === segment);
      if (exposed) {
        if (exposed.visibility === 'private') {
          const sfs = request.headers.get('sec-fetch-site')?.toLowerCase() ?? '';
          if (sfs !== 'same-origin' && sfs !== 'same-site') {
            return new Response('Forbidden: cross-site @expose call', {
              status: 403,
              headers: CORS_HEADERS,
            });
          }
        }
        const fn = (this.photon as any)[exposed.handler];
        if (typeof fn !== 'function') {
          return new Response(`Unknown handler: ${exposed.handler}`, {
            status: 500,
            headers: CORS_HEADERS,
          });
        }
        let parsed: unknown = {};
        try {
          const text = await request.text();
          if (text.length > 0) parsed = JSON.parse(text);
        } catch {
          return new Response('Invalid JSON body', { status: 400, headers: CORS_HEADERS });
        }
        try {
          const result = await fn.call(this.photon, parsed);
          if (result instanceof Response) return result;
          return Response.json(result, { headers: CORS_HEADERS });
        } catch (error: any) {
          return new Response(error?.message ?? 'Internal Server Error', {
            status: 500,
            headers: CORS_HEADERS,
          });
        }
      }
    }

    // Fall through to the [assets] binding (Track E). The wrangler.toml
    // emits this binding only when the host photon has an `assets/`
    // companion folder, so the typeof guard keeps non-asset deploys
    // quiet (no extra fetch round-trip when the binding is absent).
    const assets = (this.env as any).ASSETS as { fetch: (req: Request) => Promise<Response> } | undefined;

    // Precompiled .tsx UI: GET /api/ui/<id>[/<rest>]. Serve the shell
    // (revalidated, ETag) and the hashed bundle (immutable) from the
    // [assets] binding so the cache contract matches local + Beam.
    if (assets && request.method === 'GET') {
      const uiMatch = url.pathname.match(/^\/api\/ui\/([^/]+)(?:\/(.*))?$/);
      if (uiMatch) {
        const entry = UI_ASSET_MANIFEST[uiMatch[1]];
        if (entry) {
          const rest = (uiMatch[2] || '').replace(/^\/+/, '');
          const isBundle = rest === entry.js;
          const assetPath = isBundle
            ? `${entry.base}/${entry.js}`
            : `${entry.base}/index.html`;
          if (!isBundle) {
            const inm = request.headers.get('if-none-match');
            if (inm && inm === `"${entry.hash}"`) {
              return new Response(null, {
                status: 304,
                headers: { ETag: `"${entry.hash}"`, ...CORS_HEADERS },
              });
            }
          }
          const assetResponse = await assets.fetch(
            new Request(new URL(assetPath, url.origin).toString())
          );
          if (assetResponse.status === 200) {
            const headers = new Headers(assetResponse.headers);
            for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
            if (isBundle) {
              headers.set('Content-Type', 'text/javascript; charset=utf-8');
              headers.set('Cache-Control', 'public, max-age=31536000, immutable');
              return new Response(assetResponse.body, { status: 200, headers });
            }
            headers.set('Content-Type', 'text/html');
            headers.set('Cache-Control', 'no-cache');
            headers.set('ETag', `"${entry.hash}"`);
            // The shell references the bundle relatively (`./<hash>.js`).
            // The document URL `/api/ui/<id>` has no trailing slash, so
            // pin it to the absolute route the bundle branch serves.
            const shellHtml = (await assetResponse.text()).replace(
              `src="./${entry.js}"`,
              `src="/api/ui/${uiMatch[1]}/${entry.js}"`
            );
            headers.delete('Content-Length');
            return new Response(shellHtml, { status: 200, headers });
          }
        }
      }
    }

    if (assets && request.method === 'GET') {
      const assetResponse = await assets.fetch(request);
      if (assetResponse.status !== 404) return assetResponse;
    }

    return new Response('Not Found', { status: 404, headers: CORS_HEADERS });
  }

  /**
   * Handle a single POST /mcp body. Distinguishes JSON-RPC requests from
   * JSON-RPC responses to server-initiated requests, and chooses between a
   * plain JSON or SSE-streamed reply for tool calls based on the client's
   * Accept header.
   */
  private async _handleMcpPost(request: Request): Promise<Response> {
    let body: any;
    try {
      body = await request.json();
    } catch (err: any) {
      return Response.json(
        {
          jsonrpc: '2.0',
          id: null,
          error: { code: -32700, message: `Parse error: ${err?.message ?? String(err)}` },
        },
        { status: 400, headers: CORS_HEADERS }
      );
    }

    // JSON-RPC RESPONSE coming back to a server-initiated request: route to
    // the pending Promise, ack with 204. Distinguished by the absence of
    // `method` plus a known id in the pending map.
    if (
      body &&
      typeof body === 'object' &&
      body.method === undefined &&
      body.id !== undefined &&
      typeof body.id === 'string' &&
      this.pendingRequests.has(body.id)
    ) {
      const pending = this.pendingRequests.get(body.id)!;
      this.pendingRequests.delete(body.id);
      if (body.error) {
        pending.reject(new Error(body.error.message ?? 'Server-initiated request rejected'));
      } else {
        pending.resolve(body.result);
      }
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    const method = typeof body?.method === 'string' ? body.method : '';
    const authResult = await checkMcpAuth(
      request,
      (this as any).env,
      method,
      this.toolDefinitions,
      body
    );
    if (authResult.enforced && !authResult.ok) {
      return new Response(
        JSON.stringify({
          jsonrpc: '2.0',
          id: body?.id ?? null,
          error: {
            code: authResult.code,
            message: authResult.message,
            data: { reason: authResult.reason },
          },
        }),
        {
          status: authResult.status,
          headers: {
            'Content-Type': 'application/json',
            'WWW-Authenticate': authResult.wwwAuthenticate,
            ...CORS_HEADERS,
          },
        }
      );
    }
    const authContext = { authed: authResult.authed === true, caller: authResult.caller };

    // Tool calls from clients that signal SSE support get a streamed
    // response so this.sample / this.confirm / this.elicit can push
    // server-initiated requests inline. All other JSON-RPC methods (and
    // tool calls from JSON-only clients) use the plain JSON path.
    const accept = request.headers.get('Accept') ?? '';
    const wantsSse = accept.includes('text/event-stream');
    const sessionId = request.headers.get('Mcp-Session-Id') ?? undefined;
    if (body?.method === 'tools/call' && wantsSse) {
      return mcpAuthContext.run(authContext, () => this._streamToolCall(body, sessionId));
    }

    const result = await requestContext.run(
      {
        send: async () => {},
        pendingRequests: this.pendingRequests,
        sessionId,
      },
      () => mcpAuthContext.run(authContext, () =>
        handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions)
      )
    );
    return Response.json(result, { headers: CORS_HEADERS });
  }

  /**
   * SSE-stream a tool call response. Sets up the per-request context so
   * `this.sample` / `this.confirm` / `this.elicit` can push server-initiated
   * MCP requests over the same stream and await the client's responses
   * (which arrive as separate POST /mcp requests routed by `_handleMcpPost`).
   */
  private _streamToolCall(rpcRequest: any, sessionId?: string): Response {
    this.getPhoton();
    const { readable, writable } = new TransformStream();
    const writer = writable.getWriter();
    const encoder = new TextEncoder();
    const send = async (msg: unknown): Promise<void> => {
      await writer.write(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
    };

    // Per MCP spec, echo the client-supplied progressToken so any
    // streamed progress notifications correlate with this request.
    const clientProgressToken = (rpcRequest?.params as { _meta?: { progressToken?: string | number } })
      ?._meta?.progressToken;
    const toolName = (rpcRequest?.params as { name?: string })?.name ?? 'tool';
    const ctx: RequestContext = {
      send,
      pendingRequests: this.pendingRequests,
      progressToken: clientProgressToken ?? `progress_${toolName}`,
      sessionId,
    };

    // Fire-and-forget: the response stream stays open as long as the writer
    // is open, which keeps the DO active until the tool finishes and we
    // close the writer in `finally`.
    requestContext
      .run(ctx, async () => {
        try {
          const result = await handleMCPRequest(
            rpcRequest,
            this.photon,
            this.photonName,
            this.toolDefinitions
          );
          await send(result);
        } catch (err: any) {
          await send({
            jsonrpc: '2.0',
            id: rpcRequest?.id ?? null,
            error: { code: -32603, message: err?.message ?? String(err) },
          });
        }
      })
      .finally(() => {
        writer.close().catch(() => {
          // Stream already torn down on the client side; nothing to do.
        });
      });

    return new Response(readable, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        ...CORS_HEADERS,
      },
    });
  }

  async webSocketMessage(_ws: WebSocket, _msg: string | ArrayBuffer): Promise<void> {
    // Subscribers don't send messages in v1; ignore.
  }

  async webSocketClose(_ws: WebSocket, _code: number, _reason: string): Promise<void> {
    // No-op — ctx.getWebSockets() prunes closed sockets.
  }

  /**
   * Alarm fired by the DO scheduler. Walk every persisted schedule, dispatch
   * those that are due, advance bookkeeping, and reschedule the next alarm.
   * Per-task errors move that task to status='error' without blocking others.
   */
  async alarm(): Promise<void> {
    this.getPhoton();
    const tasks = await listSchedules(this.ctx);
    const now = Date.now();
    for (const task of tasks) {
      if (task.status !== 'active') continue;
      const fromTs = task.lastExecutionAt
        ? Date.parse(task.lastExecutionAt)
        : Date.parse(task.createdAt);
      const due = nextFireMs(task.cron, new Date(fromTs));
      if (due > now + 1000) continue;
      try {
        const fn = (this.photon as any)[task.method];
        if (typeof fn !== 'function') {
          throw new Error(`Scheduled method '${task.method}' not found on photon`);
        }
        const toolDef = this.toolDefinitions.find((t: any) => t.name === task.method);
        const callArgs = spreadArgs(toolDef, task.params || {});
        await fn.call(this.photon, ...callArgs);
        task.lastExecutionAt = new Date(now).toISOString();
        task.executionCount += 1;
        if (
          task.fireOnce ||
          (task.maxExecutions > 0 && task.executionCount >= task.maxExecutions)
        ) {
          task.status = 'completed';
        }
      } catch (err) {
        task.errorMessage = err instanceof Error ? err.message : String(err);
        task.status = 'error';
      }
      await this.ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
    }
    await rescheduleAlarm(this.ctx);
  }
}

// ════════════════════════════════════════════════════════════════════════════
// Generated DO classes — one per photon (host + every @photons sibling)
// ════════════════════════════════════════════════════════════════════════════

__PHOTON_DO_CLASSES__

// ════════════════════════════════════════════════════════════════════════════
// Outer Worker — routes every external request to the host photon DO
// ════════════════════════════════════════════════════════════════════════════

const CF_ACCESS_ENABLED = __CF_ACCESS_ENABLED__;

/**
 * Pick the photon instance name from (in priority order):
 *   1. CF Access JWT email — when @auth cf-access is set on the photon
 *   2. ?instance=<name> query param
 *   3. X-Photon-Instance header
 *   4. 'default' singleton
 *
 * CF Access verifies the JWT at the edge before the request reaches this
 * Worker, so we trust the claim without re-verifying the signature here.
 */
function canonicalizeInstance(instance: string, env: Env): string {
  const key = instance.trim().toLowerCase();
  const aliases: Record<string, string> = { ...DEPLOY_INSTANCE_ALIASES };
  const runtimeAliases = env.PHOTON_INSTANCE_ALIASES;
  if (typeof runtimeAliases === 'string' && runtimeAliases.trim()) {
    try {
      const parsed = JSON.parse(runtimeAliases) as Record<string, unknown>;
      for (const [from, to] of Object.entries(parsed)) {
        if (typeof to === 'string' && to.trim()) aliases[from.toLowerCase()] = to;
      }
    } catch (err) {
      console.warn('canonicalizeInstance: PHOTON_INSTANCE_ALIASES parse failed', err);
    }
  }
  return aliases[key] || instance;
}

function extractInstance(request: Request, env: Env): string {
  let instance: string | null = null;
  if (CF_ACCESS_ENABLED) {
    const headerEmail = request.headers.get('Cf-Access-Authenticated-User-Email');
    if (headerEmail) instance = headerEmail;
    const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
    if (!instance && jwt) {
      try {
        const part = jwt.split('.')[1];
        const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
        const padded = b64 + '==='.slice((b64.length + 3) % 4);
        const payload = JSON.parse(atob(padded));
        if (payload?.email) instance = payload.email as string;
      } catch (err) {
        console.warn('extractInstance: JWT parse failed', err);
      }
    }
  }
  const url = new URL(request.url);
  instance ??= url.searchParams.get('instance') ?? request.headers.get('X-Photon-Instance') ?? 'default';
  return canonicalizeInstance(instance, env);
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
          'Access-Control-Allow-Headers':
            'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance, Upgrade',
        },
      });
    }
    const instance = extractInstance(request, env);
    const id = env.__HOST_BINDING__.idFromName(instance);
    return env.__HOST_BINDING__.get(id).fetch(request);
  },
};

// ════════════════════════════════════════════════════════════════════════════
// Dev playground UI
// ════════════════════════════════════════════════════════════════════════════

function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${photonName} - Playground</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    :root { --bg: #0a0a0f; --card: #12121a; --border: #1e1e2e; --text: #e4e4e7; --muted: #71717a; --accent: #6366f1; --green: #22c55e; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
    .header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
    .header h1 { font-size: 18px; font-weight: 600; }
    .header h1::before { content: ''; width: 8px; height: 8px; background: var(--green); border-radius: 50%; display: inline-block; margin-right: 8px; box-shadow: 0 0 8px var(--green); }
    .badge { background: #f97316; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
    .container { display: grid; grid-template-columns: 300px 1fr; height: calc(100vh - 57px); }
    .sidebar { background: var(--card); border-right: 1px solid var(--border); padding: 16px; overflow-y: auto; }
    .tool { padding: 12px; border-radius: 8px; cursor: pointer; margin-bottom: 8px; border: 1px solid var(--border); }
    .tool:hover { border-color: var(--accent); }
    .tool.active { border-color: var(--accent); background: rgba(99, 102, 241, 0.1); }
    .tool-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
    .tool-desc { font-size: 12px; color: var(--muted); }
    .main { padding: 24px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
    .panel { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
    .panel h3 { font-size: 14px; margin-bottom: 12px; color: var(--muted); }
    .form-group { margin-bottom: 12px; }
    .form-group label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
    .form-group input, .form-group select { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; }
    .btn { background: var(--accent); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; }
    .btn:hover { opacity: 0.9; }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; }
    pre { background: var(--bg); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; }
    .preview { flex: 1; min-height: 300px; }
    .preview iframe { width: 100%; height: 100%; border: none; border-radius: 6px; background: white; }
  </style>
</head>
<body>
  <div class="header">
    <h1>${photonName}</h1>
    <span class="badge">Cloudflare Workers</span>
  </div>
  <div class="container">
    <div class="sidebar">
      <div id="tools"></div>
    </div>
    <div class="main">
      <div class="panel">
        <h3>Parameters</h3>
        <div id="params"></div>
        <button class="btn" id="call" disabled>Call Tool</button>
        <div style="margin-top: 10px; font-size: 12px; color: var(--muted);">
          <a href="#" id="set-token" style="color: var(--muted); text-decoration: underline;">Set token</a>
          &middot;
          <a href="#" id="clear-token" style="color: var(--muted); text-decoration: underline;">Clear token</a>
          <span id="token-status" style="margin-left: 8px;"></span>
        </div>
      </div>
      <div class="panel">
        <h3>Response</h3>
        <pre id="response">Select a tool to get started</pre>
      </div>
    </div>
  </div>
  <script>
    const tools = ${JSON.stringify(toolDefinitions)};
    let selectedTool = null;

    function renderTools() {
      const container = document.getElementById('tools');
      container.innerHTML = tools.map(t => \`
        <div class="tool" data-name="\${t.name}">
          <div class="tool-name">\${t.name}</div>
          <div class="tool-desc">\${t.description || ''}</div>
        </div>
      \`).join('');

      container.querySelectorAll('.tool').forEach(el => {
        el.onclick = () => selectTool(el.dataset.name);
      });
    }

    function selectTool(name) {
      selectedTool = tools.find(t => t.name === name);
      document.querySelectorAll('.tool').forEach(el => el.classList.remove('active'));
      document.querySelector(\`[data-name="\${name}"]\`).classList.add('active');
      renderParams();
      document.getElementById('call').disabled = false;
    }

    function renderParams() {
      const container = document.getElementById('params');
      const props = selectedTool?.inputSchema?.properties || {};
      const required = selectedTool?.inputSchema?.required || [];

      if (Object.keys(props).length === 0) {
        container.innerHTML = '<p style="color: var(--muted); font-size: 13px;">No parameters required</p>';
        return;
      }

      container.innerHTML = Object.entries(props).map(([key, schema]) => {
        const req = required.includes(key) ? ' *' : '';
        if (schema.enum) {
          return \`<div class="form-group">
            <label>\${key}\${req}</label>
            <select name="\${key}">
              <option value="">Select...</option>
              \${schema.enum.map(v => \`<option value="\${v}">\${v}</option>\`).join('')}
            </select>
          </div>\`;
        }
        const type = schema.type === 'number' ? 'number' : 'text';
        return \`<div class="form-group">
          <label>\${key}\${req}</label>
          <input type="\${type}" name="\${key}" placeholder="\${schema.description || ''}">
        </div>\`;
      }).join('');
    }

    document.getElementById('call').onclick = async () => {
      if (!selectedTool) return;
      const form = document.getElementById('params');
      const args = {};
      form.querySelectorAll('input, select').forEach(el => {
        if (el.value) {
          args[el.name] = el.type === 'number' ? Number(el.value) : el.value;
        }
      });

      document.getElementById('response').textContent = 'Calling...';

      try {
        const headers = { 'Content-Type': 'application/json' };
        const token = localStorage.getItem('photon_mcp_token');
        if (token) headers['Authorization'] = 'Bearer ' + token;
        const res = await fetch('/mcp', {
          method: 'POST',
          headers,
          body: JSON.stringify({
            jsonrpc: '2.0',
            id: Date.now(),
            method: 'tools/call',
            params: { name: selectedTool.name, arguments: args }
          })
        });
        if (res.status === 401 || res.status === 403) {
          document.getElementById('response').textContent = "Auth required. Click 'Set token' to paste an MCP token.";
          return;
        }
        const data = await res.json();
        document.getElementById('response').textContent = JSON.stringify(data, null, 2);
      } catch (e) {
        document.getElementById('response').textContent = 'Error: ' + e.message;
      }
    };

    function updateTokenStatus() {
      const el = document.getElementById('token-status');
      el.textContent = localStorage.getItem('photon_mcp_token') ? '(token set)' : '(no token)';
    }
    document.getElementById('set-token').onclick = (e) => {
      e.preventDefault();
      const t = prompt('Paste MCP bearer/JWT token (stored in this browser only):');
      if (t) { localStorage.setItem('photon_mcp_token', t.trim()); updateTokenStatus(); }
    };
    document.getElementById('clear-token').onclick = (e) => {
      e.preventDefault();
      localStorage.removeItem('photon_mcp_token');
      updateTokenStatus();
    };
    updateTokenStatus();

    renderTools();
  </script>
</body>
</html>`;
}
