// Payment Kit — Cloudflare Workers entry point
// Uses Hono for routing + imports original Express routes via shim adapter

// Register ethers.js CF Workers fetch adapter BEFORE any ethers imports.
// ethers v6 uses node:http by default, which is not available in CF Workers.
// This replaces the HTTP transport with native CF Workers fetch.
import { FetchRequest } from 'ethers';

import { Client as PgClient } from 'pg';
import postgres from 'postgres';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { setDB } from './shims/sequelize-d1/model';
import { initialize } from '../api/src/store/models';
import { Sequelize } from './shims/sequelize-d1/sequelize-class';

// Import the original Express routes (esbuild aliases handle all deps)
import expressRoutes from '../api/src/routes/index';
import type { RouteEntry } from './shims/express-compat/index';

// Import cron instance for scheduled handler
import { cronInstance } from './shims/cron';

// Import queue utilities
import { setWaitUntil, setCFQueue, flushPendingJobs, runAllScheduledJobs, getHandler } from './shims/queue';

// Import crons init to register all cron jobs
import crons from '../api/src/crons/index';

// Import queue modules that are NOT transitively imported by routes/crons
// so their handlers register in the CF queue shim and events listeners attach.
import '../api/src/queues/refund';
import '../api/src/queues/checkout-session';
import '../api/src/queues/discount-status';
import '../api/src/queues/exchange-rate-health';
// Event + webhook queues turn createEvent rows into actual HTTP POSTs to
// registered WebhookEndpoints. Without these, every subscription state change
// writes an Event row with pending_webhooks=99 that no one ever consumes —
// downstream apps (aistro etc.) silently never get notified of purchase /
// renewal / cancel events.
import '../api/src/queues/event';
import '../api/src/queues/webhook';

// Import security shim — initFromAuthService fetches EK from AUTH_SERVICE
import { initFromAuthService } from './shims/blocklet-sdk/security';

// D1 query timing — per-request accumulator for Server-Timing header
import { resetD1Timing, getD1Timing } from './shims/sequelize-d1/timing';

// D1 auto-retry wrapper — handles transient CF D1 errors (e.g. "Network connection lost")
import { withD1Retry } from './shims/sequelize-d1/retry';

// DID Connect: login routes proxied to blocklet-service, business actions (pay/subscribe) handled locally
import { attachDIDConnectRoutes } from './did-connect-auth';

FetchRequest.registerGetUrl(async (req: FetchRequest) => {
  const resp = await fetch(req.url, {
    method: req.method || 'GET',
    headers: { 'content-type': 'application/json' },
    body: req.hasBody() ? req.body : undefined,
  });
  const body = new Uint8Array(await resp.arrayBuffer());
  const headers: Record<string, string> = {};
  resp.headers.forEach((v: string, k: string) => {
    headers[k] = v;
  });
  return { statusCode: resp.status, statusMessage: resp.statusText, headers, body };
});

// Caller identity resolved via AUTH_SERVICE RPC (blocklet-service)
interface CallerIdentityDTO {
  did: string;
  pk: string;
  displayName: string;
  avatar: string;
  role: 'owner' | 'admin' | 'member' | 'guest';
  authMethod: 'passkey' | 'did-connect' | 'access-key' | 'oauth' | 'email';
  accessKeyId?: string;
  approved: boolean;
}

interface Env {
  DB: D1Database;
  DID_CONNECT_KV: KVNamespace;
  JOB_QUEUE: Queue;
  ASSETS: { fetch: (request: Request | string) => Promise<Response> };
  APP_SK: string;
  APP_URL: string;
  APP_NAME: string;
  APP_PID: string;
  COMPONENT_DID: string;
  MEDIA_KIT_URL: string;
  MEDIA_KIT: { fetch: (request: Request | string) => Promise<Response> };
  AUTH_SERVICE: {
    fetch: (request: Request | string) => Promise<Response>;
    resolveIdentity: (
      jwt: string | null,
      authorizationHeader: string | null,
      instanceDid?: string
    ) => Promise<CallerIdentityDTO | null>;
    verify: (jwt: string) => Promise<CallerIdentityDTO | null>;
    verifyFull: (jwt: string) => Promise<CallerIdentityDTO | null>;
    getUserByDid: (did: string) => Promise<{
      did: string;
      pk: string;
      fullName?: string;
      email?: string;
      avatar?: string;
      role?: string;
      approved?: number;
    } | null>;
  };
  HYPERDRIVE: { connectionString: string };
  [key: string]: any;
}

// === JWT identity cache — avoid repeated AUTH_SERVICE RPC for the same token ===
const JWT_CACHE_MAX_SIZE = 1000;
const JWT_CACHE_DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes fallback
const jwtIdentityCache = new Map<string, { identity: CallerIdentityDTO; expiresAt: number }>();

function getJwtExpiry(jwt: string): number | null {
  try {
    const parts = jwt.split('.');
    if (parts.length !== 3) return null;
    // Base64url decode the payload
    const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
    const decoded = JSON.parse(atob(payload));
    if (typeof decoded.exp === 'number') {
      return decoded.exp * 1000; // convert seconds to ms
    }
  } catch {
    // Fall through — use default TTL
  }
  return null;
}

function getCachedIdentity(jwt: string): CallerIdentityDTO | null {
  const entry = jwtIdentityCache.get(jwt);
  if (!entry) return null;
  if (Date.now() >= entry.expiresAt) {
    jwtIdentityCache.delete(jwt);
    return null;
  }
  return entry.identity;
}

function cacheIdentity(jwt: string, identity: CallerIdentityDTO): void {
  // Evict oldest entries if at capacity
  if (jwtIdentityCache.size >= JWT_CACHE_MAX_SIZE) {
    // Delete the first (oldest inserted) entry
    const firstKey = jwtIdentityCache.keys().next().value;
    if (firstKey) jwtIdentityCache.delete(firstKey);
  }
  const expiresAt = getJwtExpiry(jwt) ?? Date.now() + JWT_CACHE_DEFAULT_TTL_MS;
  jwtIdentityCache.set(jwt, { identity, expiresAt });
}

// === User-profile-by-DID cache — list pages hit this per row ===
// NOTE: never include email / phone / role / approved here. The endpoint that consumes this
// cache is unauthenticated and public — emitting PII would let anyone with a DID enumerate it.
interface UserProfilePayload {
  did: string;
  fullName: string;
  avatar: string;
}
const USER_PROFILE_CACHE_MAX = 500;
const POSITIVE_USER_TTL_MS = 5 * 60 * 1000;
const NEGATIVE_USER_TTL_MS = 60 * 1000;
const userProfileCache = new Map<string, { payload: UserProfilePayload; expiresAt: number }>();

function getCachedUserProfile(did: string): UserProfilePayload | null {
  const entry = userProfileCache.get(did);
  if (!entry) return null;
  if (Date.now() >= entry.expiresAt) {
    userProfileCache.delete(did);
    return null;
  }
  return entry.payload;
}

function cacheUserProfile(did: string, payload: UserProfilePayload, ttlMs: number): void {
  if (userProfileCache.size >= USER_PROFILE_CACHE_MAX) {
    const firstKey = userProfileCache.keys().next().value;
    if (firstKey) userProfileCache.delete(firstKey);
  }
  userProfileCache.set(did, { payload, expiresAt: Date.now() + ttlMs });
}

// Initialize D1 + env + models on every request
let modelsInitialized = false;
let cronsInitialized = false;

function ensureModelsInit() {
  if (!modelsInitialized) {
    try {
      const seq = new Sequelize();
      initialize(seq);
      modelsInitialized = true;
    } catch (e) {
      console.error('Model init error (non-fatal):', e);
      modelsInitialized = true; // don't retry
    }
  }
}

function ensureCronsInit() {
  if (!cronsInitialized) {
    try {
      crons.init();
      cronsInitialized = true;
    } catch (e) {
      console.error('Cron init error (non-fatal):', e);
      cronsInitialized = true;
    }
  }
}

// Security initialization: fetch EK from AUTH_SERVICE, pre-decrypt all encrypted DB values
let securityInitialized = false;

// === Build Hono app ===
// We use a factory function so DID Connect routes (which need env bindings)
// are registered in the correct order, before catch-all routes.

type HonoEnv = { Bindings: Env; Variables: { caller: CallerIdentityDTO | null } };

let cachedApp: Hono<HonoEnv> | null = null;
let cachedAppSK: string | null = null;

function buildApp(env: Env): Hono<HonoEnv> {
  // Reuse cached app within the same isolate if env hasn't changed
  if (cachedApp && cachedAppSK === (env.APP_SK || '')) {
    return cachedApp;
  }

  const app = new Hono<HonoEnv>();

  // CORS
  app.use('/api/*', cors());
  app.use('/.well-known/*', cors());
  // /__blocklet__.js is fetched by external wallets (e.g. abtwallet.io, localhost
  // dev wallets) to resolve app metadata + chain info before starting DID Connect.
  // Without CORS the browser preflight fails and DID Connect cannot proceed.
  app.use('/__blocklet__.js', cors());
  app.use('*/__blocklet__.js', cors());

  // Set up env + DB + queue on every request
  app.use('*', async (c, next) => {
    if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
      (globalThis as any).__flushDeferredTimers();
    }
    (globalThis as any).__CF_ENV__ = c.env;
    // Flag: HTTP request context. createEvent uses waitUntil (non-blocking).
    // In queue consumer/cron, this flag is absent — createEvent uses __cfPendingJobs__ (blocking).
    (globalThis as any).__cfHttpContext__ = true;
    setDB(withD1Retry(c.env.DB.withSession('first-primary')));
    if (c.env.JOB_QUEUE) setCFQueue(c.env.JOB_QUEUE);
    ensureModelsInit();

    // Sync CF env vars to process.env for source code compatibility
    if (typeof process !== 'undefined' && process.env) {
      // Stripe webhook secret: kept as env var override for webhook signature verification
      // (DB value may be empty if not configured in original Blocklet Server)
      if (c.env.STRIPE_WEBHOOK_SECRET) process.env.STRIPE_WEBHOOK_SECRET = c.env.STRIPE_WEBHOOK_SECRET;
      if (c.env.APP_URL) {
        process.env.APP_URL = c.env.APP_URL;
        process.env.BLOCKLET_APP_URL = c.env.APP_URL;
      }
      if (c.env.APP_PID) {
        process.env.BLOCKLET_APP_PID = c.env.APP_PID;
        process.env.BLOCKLET_APP_ID = c.env.APP_PID;
      }
      if (c.env.APP_NAME) process.env.BLOCKLET_APP_NAME = c.env.APP_NAME;
      if (c.env.PAYMENT_CHANGE_LOCKED_PRICE) process.env.PAYMENT_CHANGE_LOCKED_PRICE = c.env.PAYMENT_CHANGE_LOCKED_PRICE;
      if (c.env.SHORT_URL_DOMAIN) process.env.SHORT_URL_DOMAIN = c.env.SHORT_URL_DOMAIN;
      // Audience for the Pub/Sub OIDC JWT that wraps Google Play RTDN webhooks.
      // Without this mirror, libs/util.ts googlePlayEndpoint() falls back to
      // getUrl('/api/integrations/google-play/webhook') — which resolves to
      // the *.workers.dev origin, not the custom domain Pub/Sub actually
      // POSTs to. Every webhook then dies with "audience mismatch".
      if (c.env.GOOGLE_PLAY_WEBHOOK_URL) {
        process.env.GOOGLE_PLAY_WEBHOOK_URL = c.env.GOOGLE_PLAY_WEBHOOK_URL;
      }
      // Pub/Sub sender binding — without mirroring these, the google_play webhook
      // can't enforce the push service account on CF and would fail open (PR #1381 P1).
      if (c.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT) {
        process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT = c.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT;
      }
      if (c.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER) {
        process.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER = c.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER;
      }
      process.env.BLOCKLET_MODE = 'production';
    }

    // Register Stripe key decrypt overrides from env vars
    // Fetch EK from AUTH_SERVICE and initialize decrypt capability (first request only)
    if (!securityInitialized) {
      securityInitialized = true;
      await initFromAuthService(c.env);
    }

    await next();
  });

  // === Server-Timing: track total API time + auth time ===
  app.use('/api/*', async (c, next) => {
    const t0 = performance.now();

    // --- Auth ---
    const authT0 = performance.now();
    let authSource = 'none';
    const authService = c.env.AUTH_SERVICE;
    if (authService && typeof authService.resolveIdentity === 'function') {
      try {
        const cookieHeader = c.req.header('Cookie') || '';
        const match = cookieHeader.match(/(?:^|;\s*)login_token=([^;]*)/);
        const jwt = match ? decodeURIComponent(match[1]) : null;
        const authHeader = c.req.header('Authorization') || null;

        const cacheKey = jwt || authHeader;
        let caller: CallerIdentityDTO | null = null;
        if (cacheKey) {
          caller = getCachedIdentity(cacheKey);
        }

        if (caller) {
          authSource = 'cache';
        } else {
          caller = await authService.resolveIdentity(jwt, authHeader, c.env.APP_PID);
          authSource = 'rpc';
          if (caller && cacheKey) {
            cacheIdentity(cacheKey, caller);
          }
        }

        c.set('caller', caller);
      } catch (e: any) {
        console.error('[Auth] resolveIdentity error:', e?.message || e);
        c.set('caller', null);
      }
    } else {
      c.set('caller', null);
    }
    const authDur = Math.round(performance.now() - authT0);

    // --- Route handler ---
    resetD1Timing();
    await next();

    // --- Append Server-Timing header ---
    const totalDur = Math.round(performance.now() - t0);
    const d1 = getD1Timing();
    const timings = [
      `total;dur=${totalDur}`,
      `auth;dur=${authDur};desc="${authSource}"`,
    ];
    if (d1.queries > 0) {
      timings.push(`db;dur=${Math.round(d1.wallMs)};desc="${d1.queries}q ${d1.rowsRead}r"`);
      if (d1.sqlMs > 0) timings.push(`db_sql;dur=${Math.round(d1.sqlMs)}`);
    }
    // Named phases (e.g. chain, cache, ext_rpc) from measurePhase() calls
    for (const [name, dur] of Object.entries(d1.phases || {})) {
      timings.push(`${name};dur=${Math.round(dur)}`);
    }
    c.res.headers.append('Server-Timing', timings.join(', '));
  });

  // Health check
  app.get('/health', (c) => c.json({ status: 'ok' }));

  // user-session under /.well-known/service — proxy to blocklet-service session API
  // Returns full session data including connectedAccounts (needed by @arcblock/ux SessionUser)
  app.get('/.well-known/service/api/user-session', async (c) => {
    if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
    const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
    if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
    const resp = await c.env.AUTH_SERVICE.fetch(req);
    return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
  });

  // user-sessions (plural) — multi-device session list, not implemented in blocklet-service
  app.get('/.well-known/service/api/user-sessions', (c) => c.json([]));

  // Public user info by DID — resolves via AUTH_SERVICE.getUserByDid RPC.
  // PRIVACY: this endpoint is unauthenticated (anyone who knows a DID can call it),
  // so the response MUST NOT contain email / phone / role / approved or any other PII.
  // Admin-only pages that need email should fetch it via authenticated customer APIs.
  // UserCard renders rows by DID and also caches in sessionStorage on the client;
  // a small isolate-level cache absorbs burst traffic within the same request wave.
  app.get('/.well-known/service/api/user', async (c) => {
    const did = c.req.query('did') || '';
    if (!did) return c.json({ did: '', fullName: '', avatar: '' });

    const emptyResponse = () => c.json({ did, fullName: '', avatar: '' });

    const cached = getCachedUserProfile(did);
    if (cached) return c.json(cached);

    const authService = c.env.AUTH_SERVICE;
    if (!authService || typeof authService.getUserByDid !== 'function') {
      return emptyResponse();
    }

    try {
      const user = await authService.getUserByDid(did);
      if (!user) {
        cacheUserProfile(did, { did, fullName: '', avatar: '' }, NEGATIVE_USER_TTL_MS);
        return emptyResponse();
      }
      // Normalize avatar: keep inline data URIs (browsers render them directly), drop raw external
      // URLs (lh3.googleusercontent.com etc. fail under our CSP/hotlink rules, and UserCard's
      // useProxyFallback relies on a `/.well-known/service/proxy` route that isn't implemented
      // in the Workers build). Default to the same-origin avatar endpoint, which always returns
      // a deterministic SVG fallback.
      const rawAvatar = user.avatar || '';
      const normalizedAvatar = rawAvatar.startsWith('data:')
        ? rawAvatar
        : `/.well-known/service/user/avatar/${user.did}`;
      const payload = {
        did: user.did,
        fullName: user.fullName || '',
        avatar: normalizedAvatar,
      };
      cacheUserProfile(did, payload, POSITIVE_USER_TTL_MS);
      return c.json(payload);
    } catch (e: any) {
      console.error('[user-by-did] RPC error:', e?.message || e);
      return emptyResponse();
    }
  });

  // === DID Auth Service — forward all /.well-known/service/* to AUTH_SERVICE ===
  // This gives payment-kit: real login (passkey/DID wallet), dynamic branding,
  // theme, admin panel, user management — all from the shared DID service.
  app.all('/.well-known/service/*', async (c) => {
    if (c.env.AUTH_SERVICE) {
      // Add X-Instance-Did header so blocklet-service uses registered instance keys
      const req = new Request(c.req.raw);
      if (c.env.APP_PID) {
        req.headers.set('X-Instance-Did', c.env.APP_PID);
      }
      // Pass external tabs for admin/user page integration
      req.headers.set(
        'X-External-Tabs',
        JSON.stringify([{ id: 'billing', label: 'Billing', labels: { en: 'Billing', zh: '账单' }, url: '/customer' }])
      );
      const resp = await c.env.AUTH_SERVICE.fetch(req);
      // Re-create Response to ensure Set-Cookie and other headers are forwarded to the browser
      return new Response(resp.body, {
        status: resp.status,
        statusText: resp.statusText,
        headers: new Headers(resp.headers),
      });
    }
    // Fallback: no AUTH_SERVICE binding (local dev without service binding)
    return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
  });

  // __blocklet__.js — forward to AUTH_SERVICE, then override payment-kit specific fields
  // Supports ?type=json for JSON output, respects Cache-Control: private, no-store
  // Register for both root /__blocklet__.js and prefixed /x/__blocklet__.js
  for (const pattern of ['/__blocklet__.js', '*/__blocklet__.js'] as const) {
    app.get(pattern, async (c) => {
      const isJson = new URL(c.req.url).searchParams.get('type') === 'json';

      if (c.env.AUTH_SERVICE) {
        const url = new URL(c.req.url);
        // Always fetch JSON from AUTH_SERVICE for reliable parsing
        url.pathname = '/__blocklet__.js';
        url.searchParams.set('type', 'json');
        const blockletReq = new Request(url.toString(), c.req.raw);
        if (c.env.APP_PID) {
          blockletReq.headers.set('X-Instance-Did', c.env.APP_PID);
        }
        const resp = await c.env.AUTH_SERVICE.fetch(blockletReq);
        try {
          // AUTH_SERVICE may return JSON or JavaScript (`window.blocklet = {...};`)
          // depending on whether the ?type=json param is honored. Handle both by
          // extracting the JSON object literal from the response text.
          const respText = await resp.text();
          let data: Record<string, unknown>;
          try {
            data = JSON.parse(respText);
          } catch {
            const start = respText.indexOf('{');
            const end = respText.lastIndexOf('}') + 1;
            if (start < 0 || end <= start) {
              throw new Error('AUTH_SERVICE __blocklet__.js response has no JSON payload');
            }
            data = JSON.parse(respText.slice(start, end));
          }
          // Override payment-kit specific fields
          data.appPid = c.env.APP_PID || data.appPid;
          data.componentId = c.env.COMPONENT_DID || data.componentId;
          data.appUrl = c.env.APP_URL || new URL(c.req.url).origin;
          data.cloudflareWorker = true;

          // Mount prefix support: gateway passes X-Mount-Prefix header
          const mountPrefix = c.req.header('X-Mount-Prefix');
          if (mountPrefix) {
            data.prefix = mountPrefix;
            data.groupPrefix = mountPrefix;
          }

          // Append Payment Kit component mount points for admin panel integration
          const existingMounts = Array.isArray(data.componentMountPoints) ? (data.componentMountPoints as any[]) : [];
          data.componentMountPoints = [
            ...existingMounts,
            {
              did: c.env.COMPONENT_DID || 'payment-kit',
              title: 'My Account',
              name: 'customer',
              mountPoint: '/customer',
            },
          ];

          if (isJson) {
            return new Response(JSON.stringify(data), {
              headers: { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' },
            });
          }
          return new Response(`window.blocklet = ${JSON.stringify(data)};`, {
            headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'private, no-store' },
          });
        } catch {
          // Fallback: return AUTH_SERVICE response as-is
          return resp;
        }
      }
      const script = `window.blocklet = { appName: '${c.env.APP_NAME || 'Payment Kit'}', appUrl: '${c.env.APP_URL || ''}', appPid: '${c.env.APP_PID || ''}', theme: {prefer:'light'}, cloudflareWorker: true };`;
      return new Response(script, {
        headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'private, no-store' },
      });
    });
  }

  // favicon.ico — forward to AUTH_SERVICE (serves from R2 or default SVG)
  app.get('/favicon.ico', async (c) => {
    if (c.env.AUTH_SERVICE) {
      return c.env.AUTH_SERVICE.fetch(c.req.raw);
    }
    return new Response(null, { status: 404 });
  });

  // Session endpoints — resolve via AUTH_SERVICE RPC
  // /api/user-session — proxy to blocklet-service session API (same as /.well-known/service/api/user-session)
  app.get('/api/user-session', async (c) => {
    if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
    const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
    if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
    const resp = await c.env.AUTH_SERVICE.fetch(req);
    return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
  });

  // Debug endpoints
  app.get('/api/__debug__/time', (c) => c.json({ now: Date.now(), floor: Math.floor(Date.now() / 1000) }));
  app.get('/api/__debug__/sk-check', (c) => {
    const sk = c.env.APP_SK || '';
    return c.json({
      length: sk.length,
      prefix: sk.substring(0, 6),
      isHex: /^[0-9a-fA-F]+$/.test(sk),
      startsWithZ: sk.startsWith('z'),
    });
  });

  // === DID Auth Login routes ===
  // Only proxy login-related /api/did/* paths to blocklet-service.
  // Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's
  // own DID Connect actions handled by attachDIDConnectRoutes below.
  const DID_AUTH_PROXY_PATHS = [
    '/api/did/login/',
    '/api/did/session',
    '/api/did/refreshSession',
    '/api/did/connect/',
    '/api/did/logout',
  ];
  app.all('/api/did/*', async (c, next) => {
    const path = new URL(c.req.url).pathname;
    const shouldProxy = DID_AUTH_PROXY_PATHS.some((p) => path.startsWith(p) || path === p);
    if (!shouldProxy) return next(); // Fall through to attachDIDConnectRoutes or Express routes

    if (!c.env.AUTH_SERVICE) {
      return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
    }
    const url = new URL(c.req.url);
    url.pathname = `/.well-known/service${url.pathname}`;
    const req = new Request(url.toString(), c.req.raw);
    if (c.env.APP_PID) {
      req.headers.set('X-Instance-Did', c.env.APP_PID);
    }
    const resp = await c.env.AUTH_SERVICE.fetch(req);
    const headers = new Headers(resp.headers);
    return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });
  });

  // === DID Connect business actions (subscription, pay, collect, etc.) ===
  if (env.APP_SK && env.DID_CONNECT_KV) {
    try {
      attachDIDConnectRoutes(app, env.DID_CONNECT_KV, env.APP_SK);
      console.log('[CF Worker] DID Connect routes attached');
    } catch (e: any) {
      console.error('DID Connect init error:', e?.message || e);
    }
  }

  // Notification unread count
  app.get('/api/notifications/unread-count', (c) => c.json({ unReadCount: 0 }));

  // Manually trigger job dispatch (same as cron's runAllScheduledJobs)
  app.post('/api/__dev__/dispatch-jobs', async (c) => {
    const names = getAllHandlerNames();
    const result = await runAllScheduledJobs();
    return c.json({ handlers: names, ...result });
  });

  // Debug: pass through Google Play Developer API for an arbitrary subscription
  // purchase token. Returns the *raw* Google response so we can see if Google
  // itself considers the purchase valid / acknowledged / expired.
  app.get('/api/__dev__/google-play-info', async (c) => {
    const token = c.req.query('token');
    const subId = c.req.query('sub') || 'pk_demo_monthly';
    if (!token) return c.json({ error: 'token query param required' }, 400);
    const { PaymentMethod } = await import('../api/src/store/models/payment-method');
    const method = await PaymentMethod.findOne({
      where: { type: 'google_play', active: true, livemode: false } as any,
    });
    if (!method) return c.json({ error: 'no google_play PaymentMethod' }, 503);
    try {
      const client = method.getGooglePlayClient();
      const purchase = await client.getSubscription(subId, token);
      return c.json({ ok: true, sub: subId, purchase });
    } catch (err: any) {
      return c.json({ ok: false, error: err?.message || String(err) }, 500);
    }
  });

  // Bootstrap google_play PaymentMethod in staging D1 — the admin UI flow
  // for creating PaymentMethods is owner-gated, and staging has no owner yet
  // (Pengfei's blocklet-service role is `member`). Gated by PAYMENT_LIVEMODE
  // === 'false' so this only ever touches testmode data.
  // === Express-to-Hono Route Adapter ===
  mountExpressRoutes(app, '/api', expressRoutes);

  // Dev endpoint: D1 admin operations
  // Test CF Queue send directly
  app.get('/api/__dev__/queue-test', async (c) => {
    const queue = c.env.JOB_QUEUE;
    if (!queue) return c.json({ error: 'JOB_QUEUE not bound' }, 500);
    try {
      const t = Date.now();
      await queue.send({ test: true, ts: t });
      return c.json({ success: true, ms: Date.now() - t });
    } catch (err: any) {
      return c.json({ error: err?.message, name: err?.name, code: err?.code }, 500);
    }
  });

  app.post('/api/__dev__/d1-exec', async (c) => {
    const { sql } = (await c.req.json()) as { sql: string };
    if (!sql) return c.json({ error: 'sql required' }, 400);
    const db = c.env.DB;
    const result = await db.prepare(sql).all();
    return c.json({ success: true, results: result.results, meta: result.meta });
  });

  // Dev endpoint: comprehensive DB benchmark (D1 + remote PostgreSQL RTT)
  app.get('/api/__dev__/benchmark-db', async (c) => {
    const db = c.env.DB;
    const results: Record<string, any> = { d1: {}, postgres: {}, comparison: {} };

    // === D1 Benchmark ===
    // Warm-up
    await db.prepare('SELECT 1').first();

    // 1. Single SELECT
    let t = Date.now();
    await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
    results.d1.single_select_ms = Date.now() - t;

    // 2. Single INSERT + SELECT batch (simulate Model.create)
    const testId = `bench_${Date.now()}`;
    t = Date.now();
    await db.batch([
      db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
      db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
    ]);
    results.d1.insert_select_batch_ms = Date.now() - t;
    await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();

    // 3. 5x sequential SELECTs (simulate N+1 queries)
    t = Date.now();
    for (let i = 0; i < 5; i++) {
      await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
    }
    results.d1.sequential_5x_ms = Date.now() - t;
    results.d1.sequential_5x_avg_ms = Math.round(results.d1.sequential_5x_ms / 5);

    // 4. 5x batch (one D1 round-trip)
    t = Date.now();
    await db.batch([
      db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
      db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
      db.prepare('SELECT id, status FROM invoices LIMIT 1'),
      db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
      db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
    ]);
    results.d1.batch_5x_ms = Date.now() - t;

    // 5. 10x sequential
    t = Date.now();
    for (let i = 0; i < 10; i++) {
      await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
    }
    results.d1.sequential_10x_ms = Date.now() - t;
    results.d1.sequential_10x_avg_ms = Math.round(results.d1.sequential_10x_ms / 10);

    // 6. 10x batch
    t = Date.now();
    await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
    results.d1.batch_10x_ms = Date.now() - t;

    // 7. UPDATE RETURNING (simulate optimized getInvoiceNumber)
    t = Date.now();
    await db
      .prepare(
        'UPDATE _locks SET expires_at = expires_at + 1 WHERE name = (SELECT name FROM _locks LIMIT 1) RETURNING *'
      )
      .run();
    results.d1.update_returning_ms = Date.now() - t;

    // 8. Simulate full getInvoiceNumber (old): reload + increment(UPDATE + reload)
    // Using _locks as proxy table
    const lockRow = await db.prepare('SELECT name FROM _locks LIMIT 1').first();
    if (lockRow) {
      t = Date.now();
      await db
        .prepare('SELECT * FROM _locks WHERE name = ?')
        .bind((lockRow as any).name)
        .first(); // reload
      await db
        .prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ?')
        .bind((lockRow as any).name)
        .run(); // update
      await db
        .prepare('SELECT * FROM _locks WHERE name = ?')
        .bind((lockRow as any).name)
        .first(); // reload after increment
      results.d1.getInvoiceNumber_old_3RT_ms = Date.now() - t;

      t = Date.now();
      await db
        .prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ? RETURNING expires_at - 1 as prev')
        .bind((lockRow as any).name)
        .first();
      results.d1.getInvoiceNumber_new_1RT_ms = Date.now() - t;
    }

    // === PostgreSQL via Hyperdrive benchmark ===
    // Try Hyperdrive first, then direct DATABASE_URL
    const pgConnections = [
      { name: 'hyperdrive', connStr: c.env.HYPERDRIVE?.connectionString },
      { name: 'direct', connStr: c.env.DATABASE_URL },
    ].filter((x) => x.connStr);

    for (const pg of pgConnections) {
      const pgResult: Record<string, any> = {};
      results.postgres[pg.name] = pgResult;
      pgResult.connection_string = pg.connStr!.replace(/:[^:@]+@/, ':***@');

      try {
        // Connect with timeout
        t = Date.now();
        const client = new PgClient({
          connectionString: pg.connStr,
          connectionTimeoutMillis: 5000,
          ssl: pg.name === 'direct' ? { rejectUnauthorized: false } : undefined,
        });
        await client.connect();
        pgResult.connect_ms = Date.now() - t;

        // Warm-up
        await client.query('SELECT 1');

        // 1. Single SELECT
        t = Date.now();
        const r1 = await client.query('SELECT 1 as test');
        pgResult.single_select_ms = Date.now() - t;
        pgResult.single_select_result = r1.rows?.[0];

        // 2. 5x sequential SELECTs
        t = Date.now();
        for (let i = 0; i < 5; i++) {
          await client.query('SELECT 1 as test');
        }
        pgResult.sequential_5x_ms = Date.now() - t;
        pgResult.sequential_5x_avg_ms = Math.round(pgResult.sequential_5x_ms / 5);

        // 3. 10x sequential SELECTs
        t = Date.now();
        for (let i = 0; i < 10; i++) {
          await client.query('SELECT 1 as test');
        }
        pgResult.sequential_10x_ms = Date.now() - t;
        pgResult.sequential_10x_avg_ms = Math.round(pgResult.sequential_10x_ms / 10);

        await client.end();
      } catch (err: any) {
        pgResult.error = err?.message || 'Failed';
        pgResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
      }
    }

    // === postgres.js driver test (alternative to pg) ===
    const pgJsConnStr = c.env.HYPERDRIVE?.connectionString || c.env.DATABASE_URL;
    if (pgJsConnStr) {
      const pgJsResult: Record<string, any> = {};
      results.postgres.postgres_js = pgJsResult;
      try {
        t = Date.now();
        const sql = postgres(pgJsConnStr, { ssl: 'require', connect_timeout: 5, idle_timeout: 5 });
        await sql`SELECT 1 as test`;
        pgJsResult.connect_and_warmup_ms = Date.now() - t;

        t = Date.now();
        const r = await sql`SELECT 1 as test`;
        pgJsResult.single_select_ms = Date.now() - t;
        pgJsResult.single_select_result = r[0];

        t = Date.now();
        for (let i = 0; i < 5; i++) await sql`SELECT 1 as test`;
        pgJsResult.sequential_5x_ms = Date.now() - t;
        pgJsResult.sequential_5x_avg_ms = Math.round(pgJsResult.sequential_5x_ms / 5);

        t = Date.now();
        for (let i = 0; i < 10; i++) await sql`SELECT 1 as test`;
        pgJsResult.sequential_10x_ms = Date.now() - t;
        pgJsResult.sequential_10x_avg_ms = Math.round(pgJsResult.sequential_10x_ms / 10);

        await sql.end();
      } catch (err: any) {
        pgJsResult.error = err?.message || 'Failed';
        pgJsResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
      }
    }

    // === Comparison ===
    const hdResult = results.postgres.hyperdrive || {};
    const directResult = results.postgres.direct || {};
    const pgJsR = results.postgres.postgres_js || {};
    const pgSingle = hdResult.single_select_ms || directResult.single_select_ms || pgJsR.single_select_ms;
    const d1Single = results.d1.single_select_ms;
    results.comparison = {
      d1_single_ms: d1Single,
      d1_batch_5x_ms: results.d1.batch_5x_ms,
      d1_batch_5x_avg_ms: Math.round(results.d1.batch_5x_ms / 5),
      pg_single_ms: pgSingle || 'N/A',
      pg_5x_avg_ms: results.postgres.sequential_5x_avg_ms || 'N/A',
      speedup: pgSingle ? `${(d1Single / pgSingle).toFixed(1)}x` : 'N/A',
      conclusion: pgSingle
        ? d1Single > pgSingle
          ? `PG is ${(d1Single / pgSingle).toFixed(1)}x faster per query (${pgSingle}ms vs ${d1Single}ms). For 15 sequential queries: PG ~${pgSingle * 15}ms vs D1 ~${d1Single * 15}ms`
          : `D1 is faster or comparable (${d1Single}ms vs ${pgSingle}ms)`
        : 'PG benchmark failed — check Hyperdrive config',
    };

    return c.json(results);
  });

  // Legacy D1-only benchmark (kept for backward compatibility)
  app.get('/api/__dev__/d1-benchmark', async (c) => {
    const db = c.env.DB;
    const results: Record<string, any> = {};

    // 1. Single query (warm-up)
    await db.prepare('SELECT 1').first();

    // 2. Single SELECT RTT
    const t1 = Date.now();
    await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
    results['1_single_select'] = Date.now() - t1;

    // 3. 5x sequential SELECTs
    const t2 = Date.now();
    for (let i = 0; i < 5; i++) {
      await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
    }
    results['2_sequential_5x'] = Date.now() - t2;
    results['2_sequential_avg'] = Math.round(results['2_sequential_5x'] / 5);

    // 4. 5x batch (one round-trip)
    const t3 = Date.now();
    await db.batch([
      db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
      db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
      db.prepare('SELECT id, status FROM invoices LIMIT 1'),
      db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
      db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
    ]);
    results['3_batch_5x'] = Date.now() - t3;

    // 5. 5x parallel (Promise.all, separate round-trips)
    const t4 = Date.now();
    await Promise.all([
      db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first(),
      db.prepare('SELECT id, status FROM payment_intents LIMIT 1').first(),
      db.prepare('SELECT id, status FROM invoices LIMIT 1').first(),
      db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1').first(),
      db.prepare('SELECT id, type FROM payment_methods LIMIT 1').first(),
    ]);
    results['4_parallel_5x'] = Date.now() - t4;

    // 6. INSERT + SELECT batch (simulate create)
    const testId = `bench_${Date.now()}`;
    const t5 = Date.now();
    await db.batch([
      db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
      db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
    ]);
    results['5_insert_select_batch'] = Date.now() - t5;
    await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();

    // 7. 10x sequential (simulate ensureInvoiceForCheckout)
    const t6 = Date.now();
    for (let i = 0; i < 10; i++) {
      await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
    }
    results['6_sequential_10x'] = Date.now() - t6;
    results['6_sequential_10x_avg'] = Math.round(results['6_sequential_10x'] / 10);

    // 8. 10x batch
    const t7 = Date.now();
    await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
    results['7_batch_10x'] = Date.now() - t7;

    results.summary = {
      single_rtt: results['1_single_select'],
      batch_saves: `${results['2_sequential_5x'] - results['3_batch_5x']}ms for 5 queries`,
      parallel_saves: `${results['2_sequential_5x'] - results['4_parallel_5x']}ms for 5 queries`,
      batch_10x_saves: `${results['6_sequential_10x'] - results['7_batch_10x']}ms for 10 queries`,
    };

    return c.json(results);
  });

  // Dev endpoint to manually trigger cron jobs
  app.post('/api/__dev__/cron/run', async (c) => {
    ensureCronsInit();
    const body = await c.req.json().catch(() => ({}));
    const jobName = (body as any)?.job;
    if (jobName) {
      await cronInstance.runJob(jobName);
      return c.json({ ok: true, ran: jobName });
    }
    await cronInstance.runAll();
    return c.json({ ok: true, ran: 'all', jobs: cronInstance.getJobNames() });
  });

  app.get('/api/__dev__/cron/jobs', (c) => {
    ensureCronsInit();
    return c.json({ jobs: cronInstance.getJobNames() });
  });

  // Dev endpoint to run raw SQL (for migrations)
  app.get('/api/__dev__/d1/exec', async (c) => {
    const sql = c.req.query('sql');
    if (!sql) return c.json({ error: 'sql query param required' }, 400);
    try {
      const result = await c.env.DB.exec(sql);
      return c.json({ ok: true, result });
    } catch (err: any) {
      return c.json({ error: err?.message || 'exec failed' }, 500);
    }
  });

  // Dev endpoint to query D1 (returns rows)
  app.get('/api/__dev__/d1/query', async (c) => {
    const sql = c.req.query('sql');
    if (!sql) return c.json({ error: 'sql query param required' }, 400);
    try {
      const result = await c.env.DB.prepare(sql).all();
      return c.json({ ok: true, results: result.results, meta: result.meta });
    } catch (err: any) {
      return c.json({ error: err?.message || 'query failed' }, 500);
    }
  });

  // Catch-all for unimplemented API routes
  app.all('/api/*', (c) => {
    return c.json({ error: `Not yet implemented: ${c.req.method} ${c.req.path}` }, 501);
  });

  // === Media Kit Proxy ===
  // CORS preflight for media-kit (must be before app.all)
  app.options('/media-kit/*', (_c) => {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers':
          'Content-Type, Authorization, x-component-did, x-uploader-base-url, x-uploader-endpoint-url',
        'Access-Control-Max-Age': '86400',
      },
    });
  });

  // Forward /media-kit/* requests to the Media Kit CF Worker
  app.all('/media-kit/*', async (c) => {
    const url = new URL(c.req.url);
    const targetPath = url.pathname.replace(/^\/media-kit/, '') || '/';
    const mediaKitUrl = c.env.MEDIA_KIT_URL || 'https://media-kit.yexiaofang.workers.dev';

    try {
      const targetUrl = `${mediaKitUrl}${targetPath}${url.search}`;

      // Forward relevant headers
      const reqHeaders = new Headers();
      for (const h of ['content-type', 'accept', 'authorization', 'x-component-did', 'x-user-did', 'x-csrf-token']) {
        const v = c.req.header(h);
        if (v) reqHeaders.set(h, v);
      }

      // Use Service Binding if available (avoids 1042 same-zone error)
      const mediaKit = c.env.MEDIA_KIT;
      let resp: Response;
      if (mediaKit?.fetch) {
        resp = await mediaKit.fetch(
          new Request(targetUrl, {
            method: c.req.method,
            headers: reqHeaders,
            body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
          })
        );
      } else {
        // Fallback to direct fetch (may get 1042 on workers.dev)
        resp = await fetch(targetUrl, {
          method: c.req.method,
          headers: reqHeaders,
          body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
        });
      }

      const respHeaders = new Headers(resp.headers);
      respHeaders.set('Access-Control-Allow-Origin', '*');

      // Rewrite relative URLs in JSON responses to absolute URLs with /media-kit prefix
      const ct = resp.headers.get('content-type') || '';
      if (ct.includes('application/json') && resp.status >= 200 && resp.status < 300) {
        try {
          const body = (await resp.json()) as Record<string, any>;
          const { origin } = url;
          // Rewrite presignedUrl and any other relative paths
          if (body.presignedUrl && body.presignedUrl.startsWith('/')) {
            body.presignedUrl = `${origin}/media-kit${body.presignedUrl}`;
          }
          if (body.url && typeof body.url === 'string' && body.url.startsWith('/')) {
            body.url = `${origin}/media-kit${body.url}`;
          }
          if (body.fileUrl && typeof body.fileUrl === 'string' && body.fileUrl.startsWith('/')) {
            body.fileUrl = `${origin}/media-kit${body.fileUrl}`;
          }
          return new Response(JSON.stringify(body), { status: resp.status, headers: respHeaders });
        } catch {
          // If JSON parse fails, return as-is
        }
      }

      return new Response(resp.body, { status: resp.status, headers: respHeaders });
    } catch (err: any) {
      console.error('[CF Worker] Media Kit proxy error:', err?.message);
      return c.json({ error: `Media Kit proxy error: ${err?.message || 'unknown'}` }, 502);
    }
  });

  // SPA fallback — serve static assets or index.html for client-side routing
  app.all('*', async (c) => {
    const assets = c.env.ASSETS;
    if (!assets) {
      return c.text('Not found', 404);
    }

    // Helper: rewrite HTML for mount prefix support.
    //
    // The inline bootstrap in public/index.html already pulls /__blocklet__.js?type=json
    // synchronously and overlays it into window.blocklet with localOnly protection.
    // For the root-mount case we do NOT inject an extra <script src="/__blocklet__.js">,
    // because that script runs a plain `window.blocklet = {...}` assignment and wipes
    // out navigation/componentMountPoints that the bootstrap just set.
    //
    // The mountPrefix case still needs to inject a script, because the inline bootstrap
    // hardcodes `pfx + '__blocklet__.js'` based on window.blocklet.prefix which — at
    // bootstrap time — defaults to '/' and doesn't know about the gateway-provided
    // mount prefix. The injected script provides a prefix-aware fallback.
    const rewriteHtml = async (htmlResponse: Response) => {
      if (!htmlResponse.headers.get('content-type')?.includes('text/html')) return htmlResponse;
      let html = await htmlResponse.text();
      const mountPrefix = c.req.header('X-Mount-Prefix');
      if (mountPrefix && mountPrefix !== '/') {
        const pfx = mountPrefix.endsWith('/') ? mountPrefix.slice(0, -1) : mountPrefix;
        // Rewrite absolute asset paths: src="/assets/..." → src="/payment/assets/..."
        html = html.replace(/((?:src|href)=["'])\/assets\//g, `$1${pfx}/assets/`);
        // NOTE: Do NOT inject <script src="/__blocklet__.js"> here.
        // The inline bootstrap already fetches __blocklet__.js?type=json via XHR and
        // merges the remote config while protecting localOnly fields (navigation,
        // componentMountPoints). The script tag does a plain `window.blocklet = {...}`
        // assignment which OVERWRITES those fields with empty values from AUTH_SERVICE,
        // breaking sidebar navigation and causing redirect-to-home on page refresh.
      }

      // Build a fresh headers object. Copying from htmlResponse.headers keeps the
      // original Cache-Control (public, max-age=...) from the asset binding, and
      // Cloudflare's edge cache then stores the HTML — leaving users stuck on
      // stale hashed bundle references after every deploy. HTML must never be
      // edge-cached; asset files (with hashed names) are immutable and can be.
      const headers = new Headers();
      const contentType = htmlResponse.headers.get('content-type');
      if (contentType) headers.set('Content-Type', contentType);
      // no-store beats CF edge cache (no-cache only means "revalidate",
      // which CF still treats as cacheable for GET/HEAD).
      headers.set('Cache-Control', 'no-store, must-revalidate, max-age=0');
      // Cloudflare-specific override — belt-and-braces against any edge cache policy.
      headers.set('CDN-Cache-Control', 'no-store');
      headers.set('Cloudflare-CDN-Cache-Control', 'no-store');

      return new Response(html, {
        status: htmlResponse.status,
        headers,
      });
    };

    try {
      const assetResponse = await assets.fetch(c.req.raw);
      if (assetResponse.status !== 404) {
        // HTML from assets (e.g. /index.html) also needs rewriting
        if (assetResponse.headers.get('content-type')?.includes('text/html')) {
          return rewriteHtml(assetResponse);
        }
        return assetResponse;
      }
    } catch {
      // Fall through to SPA fallback
    }

    try {
      const url = new URL(c.req.url);
      url.pathname = '/index.html';
      const htmlResponse = await assets.fetch(new Request(url.toString(), c.req.raw));
      return rewriteHtml(htmlResponse);
    } catch {
      return c.text('Not found', 404);
    }
  });

  cachedApp = app;
  cachedAppSK = env.APP_SK || '';
  return app;
}

// === Express-to-Hono Route Adapter ===

function normalizeRoutePath(prefix: string, routePath: string): string {
  let full = (prefix + routePath).replace(/\/+/g, '/');
  if (!full.startsWith('/')) full = `/${full}`;
  if (full.length > 1 && full.endsWith('/')) full = full.slice(0, -1);
  return full;
}

function createExpressReq(c: any, routeParams: Record<string, string>): any {
  const url = new URL(c.req.url);
  const query: Record<string, any> = {};
  url.searchParams.forEach((v, k) => {
    query[k] = v;
  });

  const headers: Record<string, string> = {};
  c.req.raw.headers.forEach((v: string, k: string) => {
    headers[k.toLowerCase()] = v;
  });

  const req: any = {
    method: c.req.method,
    url: url.pathname + url.search,
    path: url.pathname,
    originalUrl: url.pathname + url.search,
    query,
    params: { ...routeParams },
    body: null,
    headers,
    user: null,
    // Worker-wide livemode is driven by the PAYMENT_LIVEMODE env var (set on
    // each deployment). Routes that filter PaymentMethod by livemode rely on
    // this value matching what we stored when bootstrapping the methods.
    livemode: c.env?.PAYMENT_LIVEMODE !== 'false',
    baseCurrency: null,
    ip: headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '127.0.0.1',
    get(name: string) {
      return headers[name.toLowerCase()];
    },
    header(name: string) {
      return headers[name.toLowerCase()];
    },
  };

  return req;
}

function createExpressRes(): any {
  const res: any = {
    _statusCode: 200,
    _headers: {} as Record<string, string>,
    _body: null as any,
    _sent: false,
    _redirectUrl: null as string | null,
    headersSent: false,

    status(code: number) {
      res._statusCode = code;
      return res;
    },
    json(data: any) {
      if (res._sent) return res;
      res._sent = true;
      res.headersSent = true;
      res._body = data;
      res._headers['content-type'] = 'application/json';
      return res;
    },
    send(data: any) {
      if (res._sent) return res;
      res._sent = true;
      res.headersSent = true;
      res._body = data;
      return res;
    },
    redirect(urlOrStatus: any, url?: string) {
      res._sent = true;
      res.headersSent = true;
      if (typeof urlOrStatus === 'number') {
        res._statusCode = urlOrStatus;
        res._redirectUrl = url;
      } else {
        res._statusCode = 302;
        res._redirectUrl = urlOrStatus;
      }
      return res;
    },
    set(key: string, value: string) {
      res._headers[key.toLowerCase()] = value;
      return res;
    },
    setHeader(key: string, value: string) {
      res._headers[key.toLowerCase()] = value;
      return res;
    },
    cookie(_name: string, _value: string, _options?: any) {
      return res;
    },
    end() {
      if (!res._sent) {
        res._sent = true;
        res.headersSent = true;
      }
    },
    type(t: string) {
      res._headers['content-type'] = t;
      return res;
    },
  };

  Object.defineProperty(res, 'statusCode', {
    get() {
      return res._statusCode;
    },
    set(v: number) {
      res._statusCode = v;
    },
  });

  return res;
}

function expressResToResponse(res: any): Response {
  if (res._redirectUrl) {
    return Response.redirect(res._redirectUrl, res._statusCode || 302);
  }

  const headers = new Headers(res._headers);

  if (res._body === null || res._body === undefined) {
    return new Response(null, { status: res._statusCode, headers });
  }

  if (typeof res._body === 'string') {
    return new Response(res._body, { status: res._statusCode, headers });
  }

  if (res._body instanceof ArrayBuffer || res._body instanceof Uint8Array) {
    return new Response(res._body, { status: res._statusCode, headers });
  }

  if (!headers.has('content-type')) {
    headers.set('content-type', 'application/json');
  }
  let jsonStr = JSON.stringify(res._body);
  // Rewrite legacy blocklet server URLs to CF Workers domain
  // Old format: https://old-domain/payment/methods/x.png -> https://cf-domain/methods/x.png
  const cfAppUrl = ((globalThis as any).__CF_ENV__?.APP_URL || '').replace(/\/$/, '');
  if (cfAppUrl) {
    jsonStr = jsonStr
      .split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io/payment/')
      .join(`${cfAppUrl}/`);
    jsonStr = jsonStr.split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io').join(cfAppUrl);
  }
  return new Response(jsonStr, { status: res._statusCode, headers });
}

async function runExpressHandlers(handlers: Function[], req: any, res: any): Promise<void> {
  let idx = 0;

  async function runNext(err?: any): Promise<void> {
    if (err) {
      console.error('[CF Worker] Express middleware error:', err?.message || err);
      if (!res._sent) {
        res.status(500).json({ error: err?.message || 'Internal Server Error' });
      }
      return;
    }
    if (idx >= handlers.length || res._sent) return;

    const handler = handlers[idx++];
    if (!handler) return runNext();

    if (handler.length === 4) {
      return runNext();
    }

    return new Promise<void>((resolve) => {
      let nextCalled = false;

      try {
        const result = handler(req, res, (nextErr?: any) => {
          nextCalled = true;
          runNext(nextErr)
            .then(resolve)
            .catch((e: any) => {
              if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
              resolve();
            });
        });

        if (result && typeof result.then === 'function') {
          result
            .then(() => {
              if (!nextCalled) {
                resolve();
              }
            })
            .catch((e: any) => {
              console.error('[CF Worker] Async handler error:', e?.message || e);
              if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
              resolve();
            });
        } else if (!nextCalled) {
          resolve();
        }
      } catch (e: any) {
        console.error('[CF Worker] Sync handler error:', e?.message || e);
        if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
        resolve();
      }
    });
  }

  await runNext();
}

function mountExpressRoutes(honoApp: Hono<HonoEnv>, prefix: string, expressRouter: any) {
  const routes: RouteEntry[] = expressRouter._routes || [];

  console.log(`[CF Worker] Mounting ${routes.length} Express routes under ${prefix}`);

  for (const route of routes) {
    const fullPath = normalizeRoutePath(prefix, route.path);
    const method = route.method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';

    if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
      console.warn(`[CF Worker] Skipping unsupported method: ${route.method} ${fullPath}`);
      continue;
    }

    honoApp[method](fullPath, async (c) => {
      const req = createExpressReq(c, c.req.param());
      const res = createExpressRes();

      if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method)) {
        try {
          const contentType = c.req.header('content-type') || '';
          const isStripeWebhook = fullPath.includes('/integrations/stripe/webhook');

          if (isStripeWebhook) {
            const rawBody = await c.req.arrayBuffer();
            req.body = Buffer.from(rawBody);
            req.rawBody = req.body;
          } else if (contentType.includes('application/json')) {
            req.body = await c.req.json();
          } else if (contentType.includes('application/x-www-form-urlencoded')) {
            const text = await c.req.text();
            req.body = Object.fromEntries(new URLSearchParams(text));
          } else if (contentType.includes('text/')) {
            req.body = await c.req.text();
          } else {
            try {
              req.body = await c.req.json();
            } catch {
              try {
                req.body = await c.req.text();
              } catch {
                req.body = null;
              }
            }
          }
        } catch {
          req.body = {};
        }
      }

      // Debug logging for webhook
      if (fullPath.includes('stripe/webhook')) {
        console.log('[CF Worker] Stripe webhook request received:', {
          method: c.req.method,
          path: fullPath,
          hasSignature: !!req.headers['stripe-signature'],
          bodyType: typeof req.body,
          bodyLength: req.body?.length || 0,
          isBuffer: Buffer.isBuffer(req.body),
          handlersCount: route.handlers.length,
        });
      }

      // Inject caller identity resolved by AUTH_SERVICE RPC (or mock fallback).
      // AUTH_SERVICE returns the bare base58 address; Customer/Subscription queries
      // and entitlement lookups expect the canonical `did:abt:…` form, so we
      // normalize once here at the boundary instead of in every downstream call site.
      const caller: CallerIdentityDTO | null = c.get('caller');
      if (caller) {
        const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
        req.user = {
          did: canonicalDid,
          role: caller.role || 'guest',
          provider: caller.authMethod === 'access-key' ? 'access-key' : 'wallet',
          fullName: caller.displayName || '',
          walletOS: '',
          via: 'dashboard',
        };
        req.headers['x-user-did'] = canonicalDid;
        req.headers['x-user-role'] = `blocklet-${caller.role || 'guest'}`;
        req.headers['x-user-provider'] = caller.authMethod || 'wallet';
        req.headers['x-user-fullname'] = encodeURIComponent(caller.displayName || '');
        req.headers['x-user-wallet-os'] = '';
      } else {
        req.user = { did: '', role: 'guest', provider: '', fullName: '', walletOS: '', via: '' };
      }

      try {
        await runExpressHandlers(route.handlers, req, res);
      } catch (e: any) {
        console.error(
          `[CF Worker] Unhandled error in ${route.method} ${fullPath}:`,
          e?.message || e,
          '\n',
          e?.stack?.split('\n').slice(0, 8).join('\n')
        );
        if (!res._sent) {
          res.status(500).json({ error: e?.message || 'Internal Server Error' });
        }
      }

      // Debug logging for webhook response
      if (fullPath.includes('stripe/webhook')) {
        console.log('[CF Worker] Stripe webhook handler result:', {
          sent: res._sent,
          statusCode: res._statusCode,
          body:
            typeof res._body === 'string' ? res._body.substring(0, 200) : JSON.stringify(res._body)?.substring(0, 200),
        });
      }

      // Ensure all async push() jobs complete before returning
      await flushPendingJobs();

      if (!res._sent) {
        console.warn(`[CF Worker] No response sent for ${route.method} ${fullPath}`);
        return c.json({ error: 'No response from handler' }, 500);
      }

      return expressResToResponse(res);
    });
  }
}

// === Shared env setup for scheduled/queue handlers ===
function setupEnv(env: Env) {
  if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
    (globalThis as any).__flushDeferredTimers();
  }
  (globalThis as any).__CF_ENV__ = env;
  // Queue consumer and cron: NOT HTTP context — createEvent must block (listeners complete before ack/return)
  (globalThis as any).__cfHttpContext__ = false;
  setDB(env.DB.withSession('first-primary'));
  if (env.JOB_QUEUE) setCFQueue(env.JOB_QUEUE);
  ensureModelsInit();

  if (typeof process !== 'undefined' && process.env) {
    if (env.APP_URL) {
      process.env.APP_URL = env.APP_URL;
      process.env.BLOCKLET_APP_URL = env.APP_URL;
    }
    if (env.APP_PID) {
      process.env.BLOCKLET_APP_PID = env.APP_PID;
      process.env.BLOCKLET_APP_ID = env.APP_PID;
    }
    if (env.APP_NAME) process.env.BLOCKLET_APP_NAME = env.APP_NAME;
    process.env.BLOCKLET_MODE = 'production';
  }

  // Security init is handled in the per-request middleware (first request only)
}

// === Export ===
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    setWaitUntil((p) => ctx.waitUntil(p));
    // Expose waitUntil globally for createEvent to use in HTTP context
    (globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);

    const app = buildApp(env);
    return app.fetch(request, env, ctx);
  },

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    setupEnv(env);
    ensureCronsInit();
    setWaitUntil((p) => ctx.waitUntil(p));

    console.log('Scheduled event:', event.cron, 'scheduledTime:', event.scheduledTime);

    // Refund recovery (startRefundQueue) is registered as a proper cron job in
    // api/src/crons/index.ts (refund.recovery, */5 min), so it is invoked via
    // cronInstance.runAll() below rather than on every scheduled tick.
    //
    // Pass the event's scheduledTime (the INTENDED minute) instead of letting
    // runAll() use new Date() internally. If CF delivers this event late and
    // execution crosses a minute boundary, matching on wall-clock would miss
    // exact-minute crons like "0 1 * * * *".
    await cronInstance.runAll(new Date(event.scheduledTime));
    await runAllScheduledJobs();
    await flushPendingJobs();
  },

  // CF Queue consumer — processes jobs sent by push() in the queue shim
  async queue(
    batch: MessageBatch<{ queueName: string; jobId: string; job: any; persist?: boolean }>,
    env: Env,
    ctx: ExecutionContext,
  ) {
    setupEnv(env);
    setWaitUntil((p) => ctx.waitUntil(p));

    console.log(`[queue:consumer] Received batch of ${batch.messages.length} messages`);

    for (const msg of batch.messages) {
      const { queueName, jobId, job, persist } = msg.body;

      const handler = getHandler(queueName);

      if (!handler) {
        console.error(`[queue:consumer] No handler registered for queue "${queueName}", acking message`);
        msg.ack();
        continue;
      }

      // persist defaults to true for backward compatibility and for direct
      // push() immediate jobs (where addJob wrote the row, so we must delete
      // it after onJob succeeds).
      //
      // Scheduled dispatches from runAllScheduledJobs set persist=false —
      // the dispatcher already deleted the D1 row before sending, and
      // onJob may have re-pushed a fresh row with the same id; deleting
      // again would wipe out that new row.
      const shouldPersist = persist !== false;

      try {
        console.log(`[queue:consumer] Processing ${queueName}:${jobId} (persist=${shouldPersist})`);
        await handler.executeJob(jobId, job, shouldPersist);
        console.log(`[queue:consumer] Completed ${queueName}:${jobId}`);
        msg.ack();
      } catch (err: any) {
        console.error(`[queue:consumer] Failed ${queueName}:${jobId}:`, err?.message || err);
        // Don't retry via CF Queue — job is in D1, cron will re-dispatch
        msg.ack();
      }
    }

    await flushPendingJobs();
  },
};
