import { getSession } from '@bmm/auth';
import {
  and,
  buildLogs,
  builds,
  createDb,
  desc,
  eq,
  mcpServers,
  secrets,
  sql,
  templates,
} from '@bmm/db';
import {
  BannedPatternError,
  SpecTimeoutError,
  SpecTruncatedError,
  SpecValidationError,
  generateSpec,
  pickPreviewModel,
  scanForInjection,
  streamSpecFromAnthropic,
} from '@bmm/llm';
import {
  BuildEvent,
  CreateServerInput,
  GeneratorSpec,
  IterateServerInput,
  PreviewInput,
  type SpecEdit,
  findSecretInPrompt,
} from '@bmm/types';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgBilling } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { buildPriority, getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { requireAuth } from '../plugins/session.js';
import { getForkRefTemplate } from './templates.js';

const db = createDb();

export async function serverRoutes(app: FastifyInstance): Promise<void> {
  app.get('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const rows = await db
      .select()
      .from(mcpServers)
      .where(eq(mcpServers.orgId, user.orgId))
      .orderBy(desc(mcpServers.createdAt));
    return reply.send({ servers: rows });
  });

  app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const parsed = PreviewInput.safeParse(req.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
    }
    // Never let a credential reach the LLM. Reject prompts that contain a
    // real-looking key/token before the model call. (Values belong in the
    // separate encrypted credential fields, not the prompt.)
    const leakedSecret = findSecretInPrompt(parsed.data.prompt);
    if (leakedSecret) {
      return reply.code(400).send({
        error: 'secret_in_prompt',
        detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
      });
    }

    const billing = await getOrgBilling(user.orgId);
    if (billing.suspended) {
      return reply.code(402).send({
        error: 'subscription_suspended',
        detail:
          billing.suspendedReason === 'payment_failed'
            ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
            : 'Your subscription is paused. Visit /settings/billing for details.',
        suspendedReason: billing.suspendedReason,
      });
    }
    const plan = billing.plan;

    // Daily preview rate-limit per user. Free is tight (5/day) because every
    // preview is a paid LLM call; paid tiers have headroom for real iteration.
    const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
    if (!rl.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
        plan,
        limit: PREVIEW_DAILY_LIMIT[plan],
        resetIn: rl.resetIn,
      });
    }

    const choice = pickPreviewModel(plan);

    try {
      const { spec, source } = await generateSpec(parsed.data.prompt, {
        provider: choice.provider,
        apiKey: config.ANTHROPIC_API_KEY,
        glmApiKey: config.GLM_API_KEY,
        model: choice.model,
        maxTokens: choice.maxTokens,
        timeoutMs: choice.timeoutMs,
        maxRetries: 0,
      });
      const previewId = await cacheSpec(spec);
      return reply.send({
        previewId,
        source,
        plan,
        modelDisplayName: choice.displayName,
        modelBadge: choice.displayBadge,
        upgradeHint: plan === 'hobby',
        spec: {
          name: spec.name,
          description: spec.description,
          tools: spec.tools.map((t) => ({
            name: t.name,
            description: t.description,
            inputSchema: t.inputSchema,
          })),
          requiredSecrets: spec.requiredSecrets,
          scopes: spec.scopes,
        },
      });
    } catch (err) {
      if (err instanceof SpecValidationError) {
        // Log the actual Zod validation failure so we can see *which* field
        // the model got wrong. Without this we can only see "422" in the
        // logs and can't tell whether to fix the prompt, the schema, or
        // the model output cleanup. Prompt is truncated to keep PII risk
        // bounded.
        app.log.warn(
          {
            zod_message: err.message,
            prompt: parsed.data.prompt.slice(0, 200),
            model: choice.displayName,
          },
          'preview_spec_invalid',
        );
        return reply.code(422).send({ error: 'spec_invalid', detail: err.message });
      }
      if (err instanceof BannedPatternError) {
        app.log.warn(
          {
            reason: err.message,
            prompt: parsed.data.prompt.slice(0, 200),
            model: choice.displayName,
          },
          'preview_banned_pattern',
        );
        return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
      }
      if (err instanceof SpecTimeoutError) {
        return reply.code(504).send({
          error: 'preview_timeout',
          detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
        });
      }
      if (err instanceof SpecTruncatedError) {
        app.log.warn(
          {
            reason: err.message,
            prompt: parsed.data.prompt.slice(0, 200),
            model: choice.displayName,
          },
          'preview_spec_truncated',
        );
        return reply.code(422).send({
          error: 'spec_too_large',
          detail:
            'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
        });
      }
      app.log.error(err);
      return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
    }
  });

  // Streaming preview — pipes the model's text deltas back as Server-Sent
  // Events so Cloudflare's ~100s edge cap is irrelevant: every chunk we
  // write resets the idle timer. The final event is either `spec` (success)
  // or `error` (any of the typed errors raised by the LLM layer).
  //
  // Anthropic-only for now: GLM doesn't ship a clean streaming JSON
  // contract that justifies the duplication, and hobby's 4096-token budget
  // already fits the sync path comfortably. The route falls back to the
  // sync endpoint if the request comes from a non-Anthropic tier.
  app.post('/v1/servers/preview/stream', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const parsed = PreviewInput.safeParse(req.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
    }
    // Never let a credential reach the LLM. Reject prompts that contain a
    // real-looking key/token before the model call. (Values belong in the
    // separate encrypted credential fields, not the prompt.)
    const leakedSecret = findSecretInPrompt(parsed.data.prompt);
    if (leakedSecret) {
      return reply.code(400).send({
        error: 'secret_in_prompt',
        detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
      });
    }

    const billing = await getOrgBilling(user.orgId);
    if (billing.suspended) {
      return reply.code(402).send({
        error: 'subscription_suspended',
        detail:
          billing.suspendedReason === 'payment_failed'
            ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
            : 'Your subscription is paused. Visit /settings/billing for details.',
        suspendedReason: billing.suspendedReason,
      });
    }
    const plan = billing.plan;

    const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
    if (!rl.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
        plan,
        limit: PREVIEW_DAILY_LIMIT[plan],
        resetIn: rl.resetIn,
      });
    }

    const choice = pickPreviewModel(plan);
    if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
      return reply.code(409).send({
        error: 'streaming_unavailable',
        detail:
          'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
      });
    }

    // SSE response. X-Accel-Buffering disables nginx's response buffering
    // so each chunk lands at the client immediately rather than after the
    // full response is built — critical for the keepalive-vs-CF-100s logic
    // to actually work.
    //
    // CORS note: @fastify/cors injects Access-Control-Allow-* in the onSend
    // hook, which never runs once we go straight to reply.raw — that's why
    // the browser saw "blocked by CORS policy". Set the headers manually
    // here, mirroring what the plugin would have added: credentials:true +
    // the configured app origin. The exact reflected Origin is fine because
    // we already pinned it in the cors plugin registration.
    const origin = req.headers.origin;
    if (origin && origin === config.NEXT_PUBLIC_APP_URL) {
      reply.raw.setHeader('Access-Control-Allow-Origin', origin);
      reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
      reply.raw.setHeader('Vary', 'Origin');
    }
    reply.raw.setHeader('Content-Type', 'text/event-stream');
    reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
    reply.raw.setHeader('Connection', 'keep-alive');
    reply.raw.setHeader('X-Accel-Buffering', 'no');
    reply.raw.flushHeaders();

    const send = (event: string, data: unknown) => {
      reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
    };

    // Heartbeat comment every 15s. Cloudflare's edge keeps the connection
    // open as long as bytes flow; comments are SSE-noop but count as bytes.
    const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
    const abort = new AbortController();
    req.raw.on('close', () => {
      abort.abort();
      clearInterval(keepalive);
    });

    // `resolved` is set inside the awaited handlers below — by the time
    // streamSpecFromAnthropic returns, exactly one of onSpec/onError will
    // have completed (handlers are awaited inside the llm package), so the
    // post-stream fallback `if (!resolved)` only fires if the stream truly
    // ended without either handler running (which would be a programming
    // bug, not a runtime path).
    let resolved = false;
    try {
      await streamSpecFromAnthropic(
        parsed.data.prompt,
        {
          apiKey: config.ANTHROPIC_API_KEY,
          model: choice.model,
          maxTokens: choice.maxTokens,
          signal: abort.signal,
        },
        {
          onText: (delta) => send('text', delta),
          onSpec: async ({ spec, source }) => {
            const previewId = await cacheSpec(spec);
            send('spec', {
              previewId,
              source,
              plan,
              modelDisplayName: choice.displayName,
              modelBadge: choice.displayBadge,
              upgradeHint: plan === 'hobby',
              spec: {
                name: spec.name,
                description: spec.description,
                tools: spec.tools.map((t) => ({
                  name: t.name,
                  description: t.description,
                  inputSchema: t.inputSchema,
                })),
                requiredSecrets: spec.requiredSecrets,
                scopes: spec.scopes,
              },
            });
            app.log.info(
              {
                previewId,
                tools: spec.tools.length,
                prompt: parsed.data.prompt.slice(0, 200),
                model: choice.displayName,
              },
              'preview_spec_ready',
            );
            resolved = true;
          },
          onError: (err) => {
            if (err instanceof SpecTruncatedError) {
              app.log.warn(
                {
                  reason: err.message,
                  prompt: parsed.data.prompt.slice(0, 200),
                  model: choice.displayName,
                },
                'preview_spec_truncated',
              );
              send('error', {
                error: 'spec_too_large',
                detail:
                  'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
              });
            } else if (err instanceof SpecValidationError) {
              app.log.warn(
                {
                  zod_message: err.message,
                  prompt: parsed.data.prompt.slice(0, 200),
                  model: choice.displayName,
                },
                'preview_spec_invalid',
              );
              send('error', { error: 'spec_invalid', detail: err.message });
            } else if (err instanceof BannedPatternError) {
              send('error', { error: 'banned_pattern', detail: err.message });
            } else if (err instanceof SpecTimeoutError) {
              send('error', {
                error: 'preview_timeout',
                detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
              });
            } else {
              app.log.error(err);
              send('error', { error: 'preview_failed', detail: err.message });
            }
            resolved = true;
          },
        },
      );

      if (!resolved) {
        app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
        send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
      }
    } catch (err) {
      // If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
      // or a network error before either handler runs) we must still tear down
      // the keepalive timer and close the socket — otherwise the interval keeps
      // writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
      app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
      if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
    } finally {
      clearInterval(keepalive);
      reply.raw.end();
    }
  });

  app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const parsed = CreateServerInput.safeParse(req.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
    }
    const {
      name,
      slug,
      prompt,
      secrets: secretValues,
      previewId,
      specEdit,
      templateId,
    } = parsed.data;

    // ---- Plan enforcement (must happen before any DB write) ----
    const billing = await getOrgBilling(user.orgId);
    if (billing.suspended) {
      return reply.code(402).send({
        error: 'subscription_suspended',
        detail:
          billing.suspendedReason === 'payment_failed'
            ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
            : 'Your subscription is paused. Visit /settings/billing for details.',
        suspendedReason: billing.suspendedReason,
      });
    }
    const plan = billing.plan;

    // Daily build rate-limit.
    const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);
    if (!rl.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: `Daily build limit reached for plan "${plan}" (${BUILD_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
        plan,
        limit: BUILD_DAILY_LIMIT[plan],
        resetIn: rl.resetIn,
      });
    }

    // Server-count quota. Counted via SQL (not cached) so race risk is tiny.
    const [serverCountRow] = await db
      .select({ count: sql<number>`count(*)::int` })
      .from(mcpServers)
      .where(eq(mcpServers.orgId, user.orgId));
    const existingCount = serverCountRow?.count ?? 0;
    if (existingCount >= SERVER_LIMITS[plan]) {
      return reply.code(402).send({
        error: 'plan_limit_reached',
        detail: `Plan "${plan}" allows ${SERVER_LIMITS[plan]} server(s); you have ${existingCount}. Upgrade to add more.`,
        plan,
        limit: SERVER_LIMITS[plan],
        current: existingCount,
      });
    }

    // ---- Template-fork validation ----
    // templateId is user-controlled. To prevent fork_count manipulation + garbage
    // template_id rows, the user MUST have hit POST /v1/templates/:slug/fork,
    // which created a Redis fork-ref keyed by previewId. We verify both exist and match.
    let validatedTemplateId: string | null = null;
    if (templateId) {
      if (!previewId) {
        return reply.code(400).send({ error: 'preview_id_required_for_fork' });
      }
      const refTemplateId = await getForkRefTemplate(previewId);
      if (!refTemplateId) {
        return reply.code(410).send({
          error: 'fork_ref_expired',
          detail: 'Re-open the template and click Fork again.',
        });
      }
      if (refTemplateId !== templateId) {
        return reply.code(400).send({ error: 'fork_ref_mismatch' });
      }
      if (specEdit) {
        return reply.code(400).send({
          error: 'spec_edit_forbidden_on_fork',
          detail:
            'Forked templates ship pre-built code. Iterate after build to change tool behavior.',
        });
      }
      validatedTemplateId = templateId;
    }

    // If the user edited the spec in step 2 of the wizard, merge their edits into
    // the cached spec (keeping the original tool implementations untouched).
    if (specEdit) {
      if (!previewId) {
        return reply.code(400).send({ error: 'preview_id_required_with_edit' });
      }
      const cached = await loadSpec(previewId);
      if (!cached) {
        return reply.code(410).send({ error: 'preview_expired' });
      }
      const merged = mergeSpecEdit(cached, specEdit);
      const validation = GeneratorSpec.safeParse(merged);
      if (!validation.success) {
        return reply
          .code(422)
          .send({ error: 'spec_invalid_after_edit', detail: validation.error.flatten() });
      }
      try {
        rescanInjection(validation.data);
      } catch (err) {
        return reply.code(422).send({ error: 'banned_pattern', detail: (err as Error).message });
      }
      await overwriteSpec(previewId, validation.data);
    }

    const existing = await db
      .select({ id: mcpServers.id })
      .from(mcpServers)
      .where(and(eq(mcpServers.orgId, user.orgId), eq(mcpServers.slug, slug)))
      .limit(1);
    if (existing.length > 0) {
      return reply.code(409).send({ error: 'slug_taken' });
    }

    const [server] = await db
      .insert(mcpServers)
      .values({
        orgId: user.orgId,
        slug,
        name,
        status: 'queued',
        templateId: validatedTemplateId,
      })
      .returning();
    if (!server) return reply.code(500).send({ error: 'create_failed' });

    if (validatedTemplateId) {
      await db
        .update(templates)
        .set({ forkCount: sql`${templates.forkCount} + 1`, updatedAt: new Date() })
        .where(eq(templates.id, validatedTemplateId));
    }

    for (const [key, value] of Object.entries(secretValues)) {
      if (!value) continue;
      const enc = encryptSecret(value);
      await db.insert(secrets).values({
        serverId: server.id,
        key,
        encryptedValue: enc.value,
        keyId: enc.keyId,
      });
    }

    const [build] = await db
      .insert(builds)
      .values({ serverId: server.id, version: 1, prompt, status: 'queued' })
      .returning();
    if (!build) return reply.code(500).send({ error: 'build_create_failed' });

    await getBuildQueue().add(
      'generate',
      {
        buildId: build.id,
        serverId: server.id,
        orgId: user.orgId,
        prompt,
        version: 1,
        slug,
        serverName: name,
        secrets: secretValues,
        previewId,
      },
      { priority: buildPriority(plan) },
    );

    await audit({
      orgId: user.orgId,
      userId: user.userId,
      action: 'server.create',
      resourceType: 'server',
      resourceId: server.id,
      metadata: { slug, name, previewId: previewId ?? null },
      ipAddress: req.ip,
    });

    return reply.send({ server, build });
  });

  app.get('/v1/servers/:id', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const Params = z.object({ id: z.string().uuid() });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });

    const [server] = await db
      .select()
      .from(mcpServers)
      .where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId)))
      .limit(1);
    if (!server) return reply.code(404).send({ error: 'not_found' });

    const latestBuilds = await db
      .select()
      .from(builds)
      .where(eq(builds.serverId, server.id))
      .orderBy(desc(builds.version))
      .limit(10);

    return reply.send({ server, builds: latestBuilds });
  });

  app.post('/v1/servers/:id/iterate', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const Params = z.object({ id: z.string().uuid() });
    const parsedParams = Params.safeParse(req.params);
    if (!parsedParams.success) return reply.code(400).send({ error: 'invalid_id' });
    const parsed = IterateServerInput.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });

    const [server] = await db
      .select()
      .from(mcpServers)
      .where(and(eq(mcpServers.id, parsedParams.data.id), eq(mcpServers.orgId, user.orgId)))
      .limit(1);
    if (!server) return reply.code(404).send({ error: 'not_found' });

    // iterate queues a full paid LLM build exactly like POST /v1/servers, so it
    // must enforce the same suspension + daily-build gates. Without these a
    // suspended (non-paying) or rate-capped org could generate unlimited builds
    // by hitting iterate instead of create. (SRV-003)
    const billing = await getOrgBilling(user.orgId);
    if (billing.suspended) {
      return reply.code(402).send({
        error: 'subscription_suspended',
        detail:
          billing.suspendedReason === 'payment_failed'
            ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
            : 'Your subscription is paused. Visit /settings/billing for details.',
        suspendedReason: billing.suspendedReason,
      });
    }
    const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
    if (!iterateRl.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
        plan: billing.plan,
        limit: BUILD_DAILY_LIMIT[billing.plan],
        resetIn: iterateRl.resetIn,
      });
    }

    const nextVersion = server.currentVersion + 1;
    const [build] = await db
      .insert(builds)
      .values({
        serverId: server.id,
        version: nextVersion,
        prompt: parsed.data.prompt,
        status: 'queued',
      })
      .returning();
    if (!build) return reply.code(500).send({ error: 'build_create_failed' });

    await db
      .update(mcpServers)
      .set({ status: 'queued', updatedAt: new Date() })
      .where(eq(mcpServers.id, server.id));

    await getBuildQueue().add(
      'generate',
      {
        buildId: build.id,
        serverId: server.id,
        orgId: user.orgId,
        prompt: parsed.data.prompt,
        version: nextVersion,
        slug: server.slug,
        serverName: server.name,
        secrets: parsed.data.secrets,
      },
      { priority: buildPriority(billing.plan) },
    );

    await audit({
      orgId: user.orgId,
      userId: user.userId,
      action: 'server.iterate',
      resourceType: 'server',
      resourceId: server.id,
      metadata: { version: nextVersion },
      ipAddress: req.ip,
    });

    return reply.send({ build });
  });

  app.get('/v1/builds/:id', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const Params = z.object({ id: z.string().uuid() });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });

    const [row] = await db
      .select({ build: builds, server: mcpServers })
      .from(builds)
      .innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
      .where(eq(builds.id, parsed.data.id))
      .limit(1);
    if (!row || row.server.orgId !== user.orgId) {
      return reply.code(404).send({ error: 'not_found' });
    }
    const logs = await db
      .select()
      .from(buildLogs)
      .where(eq(buildLogs.buildId, row.build.id))
      .orderBy(buildLogs.timestamp);

    return reply.send({ build: row.build, logs, server: row.server });
  });

  // WebSocket — live build stream
  app.get('/v1/builds/:id/stream', { websocket: true }, async (socket, req) => {
    const fail = (message: string) => {
      socket.send(JSON.stringify({ type: 'error', message, at: new Date().toISOString() }));
      socket.close();
    };

    // Defense-in-depth Origin check. SameSite=Lax cookies already block
    // cross-origin WS from JS in spec-compliant browsers, but enforcing the
    // Origin server-side closes any browser-bug or non-browser-client path.
    const origin = req.headers.origin;
    if (origin && origin !== config.NEXT_PUBLIC_APP_URL) {
      return fail('cross_origin_ws_rejected');
    }

    const Params = z.object({ id: z.string().uuid() });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return fail('invalid_id');
    const buildId = parsed.data.id;

    // API-SERVERS-002: the WS endpoint must authorize like its REST twin.
    // Authenticate from the session cookie and confirm the build belongs to
    // the caller's org before streaming anything.
    const session = await getSession(req.cookies['bmm_session']);
    if (!session) return fail('unauthorized');

    const [owned] = await db
      .select({ orgId: mcpServers.orgId })
      .from(builds)
      .innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
      .where(eq(builds.id, buildId))
      .limit(1);
    if (!owned || owned.orgId !== session.orgId) return fail('not_found');

    // Replay any persisted logs first
    const logs = await db
      .select()
      .from(buildLogs)
      .where(eq(buildLogs.buildId, buildId))
      .orderBy(buildLogs.timestamp);
    for (const log of logs) {
      const level: 'info' | 'warn' | 'error' =
        log.level === 'warn' || log.level === 'error' ? log.level : 'info';
      socket.send(
        JSON.stringify({
          type: 'log',
          level,
          message: log.message,
          at: log.timestamp.toISOString(),
        } satisfies BuildEvent),
      );
    }
    const [b] = await db.select().from(builds).where(eq(builds.id, buildId)).limit(1);
    if (b) {
      socket.send(
        JSON.stringify({
          type: 'status',
          status: b.status,
          at: new Date().toISOString(),
        } satisfies BuildEvent),
      );
    }

    const channel = buildChannel(buildId);
    const subscriber = getSubscriber().duplicate();
    await subscriber.subscribe(channel);
    subscriber.on('message', (_chan, payload) => {
      try {
        const evt = JSON.parse(payload);
        socket.send(JSON.stringify(evt));
        if (evt.type === 'done' || evt.type === 'error') {
          setTimeout(() => socket.close(), 250);
        }
      } catch (e) {
        app.log.warn({ err: e }, 'invalid build event payload');
      }
    });

    socket.on('close', () => {
      subscriber.unsubscribe(channel).catch(() => undefined);
      subscriber.disconnect();
    });
  });

  app.delete('/v1/servers/:id', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const Params = z.object({ id: z.string().uuid() });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
    const [server] = await db
      .select()
      .from(mcpServers)
      .where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId)))
      .limit(1);
    if (!server) return reply.code(404).send({ error: 'not_found' });
    // API-SERVERS-001: tear down the running container before deleting the row,
    // otherwise it keeps serving traffic with the user's secrets baked in.
    let containerStopped = false;
    if (server.containerId) {
      const result = await stopContainer(server.containerId, server.slug);
      containerStopped = result.ok;
      if (!result.ok) {
        app.log.warn(
          { containerId: server.containerId, detail: result.detail },
          'delete: stop failed',
        );
      }
    }
    await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
    await audit({
      orgId: user.orgId,
      userId: user.userId,
      action: 'server.delete',
      resourceType: 'server',
      resourceId: server.id,
      metadata: { slug: server.slug, name: server.name, containerStopped },
      ipAddress: req.ip,
    });
    return reply.send({ ok: true });
  });
}

// ---- Spec-edit merge helpers ----

/** Delegates to @bmm/llm's scanForInjection so the banned-pattern set is
 *  defined exactly once. Wraps the thrown BannedPatternError into a plain
 *  Error for the existing catch path in /v1/servers POST. */
function rescanInjection(spec: GeneratorSpec): void {
  try {
    scanForInjection(spec);
  } catch (err) {
    if (err instanceof BannedPatternError) {
      throw new Error(err.message);
    }
    throw err;
  }
}

function mergeSpecEdit(cached: GeneratorSpec, edit: SpecEdit): GeneratorSpec {
  // Index-based merge: user can edit tool name/description/inputSchema but cannot add or
  // remove tools — that requires fresh generation. Implementation stays from cache so the
  // LLM-generated code is preserved as-is.
  if (edit.tools.length !== cached.tools.length) {
    throw new Error(
      `tool_count_mismatch: cached ${cached.tools.length}, edit ${edit.tools.length}`,
    );
  }
  const mergedTools = edit.tools.map((editTool, i) => {
    const original = cached.tools[i];
    if (!original) {
      throw new Error(`tool_index_missing: ${i}`);
    }
    return {
      name: editTool.name,
      description: editTool.description,
      inputSchema: editTool.inputSchema,
      implementation: original.implementation,
    };
  });
  return {
    ...cached,
    tools: mergedTools,
    requiredSecrets: edit.requiredSecrets,
  };
}
