import crypto from 'node:crypto';
import { getSession } from '@bmm/auth';
import {
  and,
  builds,
  count,
  createDb,
  desc,
  eq,
  gte,
  mcpServers,
  organizations,
  sql,
  templates,
  users,
} from '@bmm/db';
import { SHARED_BANNED_PATTERNS } from '@bmm/llm';
import { GeneratorSpec } from '@bmm/types';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { stopContainer } from '../lib/docker.js';
import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';

const db = createDb();

// Code-level extras on top of SHARED_BANNED_PATTERNS — these are concerns
// that only make sense scanning a fully-rendered server.ts (not a spec).
// Keeping them additive means @bmm/llm stays the single source of truth for
// "obvious-malicious patterns", and publish-time gets stricter checks on top.
// (Zc-001 consolidation.)
const CODE_EXTRA_PATTERNS: RegExp[] = [
  /\bimport\s*\(/, // dynamic import (escape from bundle scope)
  /\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
  /\bsetInterval\s*\(\s*['"`]/,
  /\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
  /\bprocess\s*\.\s*kill\b/,
  /you\s+are\s+now\s+(in\s+)?(developer|jailbreak|dan)\s+mode/i,
];
const PUBLISH_BANNED_PATTERNS: readonly RegExp[] = [
  ...SHARED_BANNED_PATTERNS,
  ...CODE_EXTRA_PATTERNS,
];

// Hardcoded-credential patterns. If Claude embedded a literal API key into the
// generated code (publisher pasted it into the prompt), block the publish.
const SECRET_PATTERNS = [
  { name: 'anthropic_key', re: /\bsk-ant-(?:api|sid)\d+-[A-Za-z0-9_-]{20,}/ },
  { name: 'openai_key', re: /\bsk-[A-Za-z0-9_-]{30,}/ },
  { name: 'stripe_secret', re: /\bsk_(live|test)_[A-Za-z0-9]{20,}/ },
  { name: 'github_pat', re: /\bghp_[A-Za-z0-9]{30,}/ },
  { name: 'github_fine_grained', re: /\bgithub_pat_[A-Za-z0-9_]{30,}/ },
  { name: 'slack_token', re: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/ },
  { name: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/ },
  { name: 'rsa_private_key', re: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/i },
];

function scanForInjection(code: string): void {
  for (const pattern of PUBLISH_BANNED_PATTERNS) {
    if (pattern.test(code)) throw new Error(`banned_pattern: ${pattern.source}`);
  }
}

function scanForLeakedSecrets(code: string): void {
  for (const { name, re } of SECRET_PATTERNS) {
    if (re.test(code)) {
      throw new Error(
        `hardcoded_${name}_detected: a literal credential was found in the generated code; remove it before publishing`,
      );
    }
  }
}

const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;

// Per-fork link: ties a previewId back to the template it came from. Set during
// fork, consumed by the create-server endpoint to prove the user actually went
// through the fork flow before we accept templateId or bump forkCount.
const FORK_REF_TTL_SECONDS = 5 * 60;
async function setForkRef(previewId: string, templateId: string): Promise<void> {
  await getRedis().set(`fork-ref:${previewId}`, templateId, 'EX', FORK_REF_TTL_SECONDS);
}
export async function getForkRefTemplate(previewId: string): Promise<string | null> {
  return (await getRedis().get(`fork-ref:${previewId}`)) ?? null;
}

const CATEGORIES = [
  'productivity',
  'developer-tools',
  'data',
  'communication',
  'finance',
  'crm',
  'analytics',
  'devops',
  'demo',
  'other',
] as const;

const SecretHint = z.object({
  key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
  description: z.string().min(1).max(280),
  howToGetUrl: z.string().url().optional(),
});

const PublishInput = z.object({
  serverId: z.string().uuid(),
  title: z.string().min(3).max(128),
  shortDescription: z.string().min(10).max(280),
  longDescription: z.string().max(8000).optional(),
  category: z.enum(CATEGORIES),
  secretHints: z.array(SecretHint).max(30).default([]),
  allowedDomains: z.array(z.string()).max(50).optional(),
});

export async function templateRoutes(app: FastifyInstance): Promise<void> {
  // ---- Publish (user → template) ----
  app.post('/v1/templates', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const parsed = PublishInput.safeParse(req.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
    }

    const [server] = await db
      .select()
      .from(mcpServers)
      .where(and(eq(mcpServers.id, parsed.data.serverId), eq(mcpServers.orgId, user.orgId)))
      .limit(1);
    if (!server) return reply.code(404).send({ error: 'server_not_found' });
    if (server.status !== 'live') {
      return reply
        .code(400)
        .send({ error: 'server_not_live', detail: 'Only live servers can be published.' });
    }
    if (!server.toolsSchema) {
      return reply.code(400).send({ error: 'no_tools_schema' });
    }

    // Get the last successful build's generated code (this is the implementation)
    const [build] = await db
      .select()
      .from(builds)
      .where(and(eq(builds.serverId, server.id), eq(builds.status, 'success')))
      .orderBy(desc(builds.version))
      .limit(1);
    if (!build || !build.generatedCode) {
      return reply.code(400).send({ error: 'no_generated_code' });
    }

    // Re-validate code against banned patterns AND hardcoded secrets
    try {
      scanForInjection(build.generatedCode);
      scanForLeakedSecrets(build.generatedCode);
    } catch (err) {
      return reply.code(422).send({ error: 'publish_blocked', detail: (err as Error).message });
    }

    // Build a unique template slug
    const baseSlug = parsed.data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
      .slice(0, 48);
    let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
    let attempt = 0;
    while (true) {
      const existing = await db
        .select({ id: templates.id })
        .from(templates)
        .where(eq(templates.slug, slug))
        .limit(1);
      if (existing.length === 0) break;
      attempt++;
      slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
      if (attempt > 5) {
        return reply.code(500).send({ error: 'slug_conflict' });
      }
    }

    const [template] = await db
      .insert(templates)
      .values({
        ownerUserId: user.userId,
        ownerOrgId: user.orgId,
        sourceServerId: server.id,
        slug,
        title: parsed.data.title,
        shortDescription: parsed.data.shortDescription,
        longDescription: parsed.data.longDescription ?? null,
        category: parsed.data.category,
        toolsSchema: server.toolsSchema,
        generatedCode: build.generatedCode,
        requiredSecrets: parsed.data.secretHints,
        // Aggregate the distinct scopes actually declared by the server's tools
        // (deduped), falling back to read-only. The previous reduce ignored its
        // input and hardcoded ['mcp:read'] for every template regardless of what
        // its tools did. (TPL-003)
        scopes: (() => {
          const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
          const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
          return all.length > 0 ? all : ['mcp:read'];
        })(),
        allowedDomains: parsed.data.allowedDomains ?? null,
      })
      .returning();
    if (!template) return reply.code(500).send({ error: 'template_create_failed' });

    await audit({
      orgId: user.orgId,
      userId: user.userId,
      action: 'template.publish',
      resourceType: 'template',
      resourceId: template.id,
      metadata: { slug, title: parsed.data.title, fromServerId: server.id },
      ipAddress: req.ip,
    });
    return reply.send({ template });
  });

  // ---- "Is this server already published?" (owner lookup, drives the detail-page tab) ----
  app.get('/v1/servers/:id/template', { 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' });

    // Verify the server belongs to the caller's org
    const [server] = await db
      .select({ id: mcpServers.id })
      .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 [template] = await db
      .select({
        id: templates.id,
        slug: templates.slug,
        title: templates.title,
        status: templates.status,
        verified: templates.verified,
        forkCount: templates.forkCount,
      })
      .from(templates)
      .where(eq(templates.sourceServerId, parsed.data.id))
      .orderBy(desc(templates.createdAt))
      .limit(1);

    return reply.send({ template: template ?? null });
  });

  // ---- Owner visibility toggle (unshare / re-share anytime) ----
  app.patch('/v1/templates/:slug/visibility', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
    const Body = z.object({ shared: z.boolean() });
    const p = Params.safeParse(req.params);
    const b = Body.safeParse(req.body);
    if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });

    const [template] = await db
      .select()
      .from(templates)
      .where(eq(templates.slug, p.data.slug))
      .limit(1);
    if (!template) return reply.code(404).send({ error: 'not_found' });

    // Only the owner can toggle their own template. Admins use /v1/admin/templates.
    if (template.ownerUserId !== user.userId) {
      return reply.code(403).send({ error: 'forbidden' });
    }
    // A template the admin took down cannot be re-shared by the owner.
    if (template.status === 'takedown') {
      return reply.code(409).send({ error: 'taken_down', detail: template.takedownReason });
    }

    const nextStatus = b.data.shared ? 'public' : 'hidden';
    await db
      .update(templates)
      .set({ status: nextStatus, updatedAt: new Date() })
      .where(eq(templates.id, template.id));

    await audit({
      orgId: user.orgId,
      userId: user.userId,
      action: b.data.shared ? 'template.reshare' : 'template.unshare',
      resourceType: 'template',
      resourceId: template.id,
      metadata: { slug: template.slug },
      ipAddress: req.ip,
    });
    return reply.send({ ok: true, status: nextStatus });
  });

  // ---- Public list with ranking ----
  app.get('/v1/templates', async (req, reply) => {
    const Query = z.object({
      category: z.string().optional(),
      sort: z.enum(['trending', 'top', 'newest']).default('trending'),
      limit: z.coerce.number().min(1).max(100).default(50),
    });
    const parsed = Query.safeParse(req.query);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });

    const rows = await db
      .select({
        template: templates,
        ownerName: users.name,
        ownerEmail: users.email,
        ownerOrgName: organizations.name,
      })
      .from(templates)
      .leftJoin(users, eq(users.id, templates.ownerUserId))
      .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
      // Category filter belongs in the WHERE, BEFORE limit — filtering in JS
      // after `.limit(50)` meant `?category=x` searched only the 50 newest
      // public templates (any category), returning far fewer than `limit`. (TPL-008)
      .where(
        and(
          eq(templates.status, 'public'),
          parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
        ),
      )
      .orderBy(desc(templates.createdAt))
      .limit(parsed.data.limit);

    const filtered = rows;

    // Single grouped query — was N+1 (one COUNT per template). On a 100-row
    // listing that's 101 round-trips → p95 latency cliff once the marketplace
    // grows. (Zc-002.)
    const templateIds = filtered.map((r) => r.template.id);
    const activeCounts = new Map<string, number>();
    if (templateIds.length > 0) {
      const grouped = await db
        .select({ id: mcpServers.templateId, c: count() })
        .from(mcpServers)
        .where(and(eq(mcpServers.status, 'live'), sql`${mcpServers.templateId} = ANY(${templateIds})`))
        .groupBy(mcpServers.templateId);
      for (const g of grouped) {
        if (g.id) activeCounts.set(g.id, Number(g.c));
      }
    }
    const enriched = filtered.map((r) => ({
      ...r.template,
      ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null,
      ownerOrgName: r.ownerOrgName,
      activeDeployments: activeCounts.get(r.template.id) ?? 0,
    }));

    // Sort
    const now = Date.now();
    const ranked = [...enriched].sort((a, b) => {
      if (parsed.data.sort === 'newest') {
        return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
      }
      if (parsed.data.sort === 'top') {
        return b.forkCount - a.forkCount;
      }
      // trending: (active*3 + forks) / sqrt(ageDays + 1)
      const score = (t: (typeof enriched)[number]): number => {
        const ageDays = Math.max(1, (now - new Date(t.createdAt).getTime()) / 86_400_000);
        return (t.activeDeployments * 3 + t.forkCount) / Math.sqrt(ageDays);
      };
      return score(b) - score(a);
    });

    return reply.send({ templates: ranked, categories: CATEGORIES });
  });

  // ---- My templates (authed — all statuses, for the marketplace "Mine" filter) ----
  // Registered before /:slug so the static segment always wins the router match.
  app.get('/v1/templates/mine', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const rows = await db
      .select()
      .from(templates)
      .where(eq(templates.ownerUserId, user.userId))
      .orderBy(desc(templates.createdAt));

    const enriched = await Promise.all(
      rows.map(async (t) => {
        const [active] = await db
          .select({ c: count() })
          .from(mcpServers)
          .where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
        return {
          ...t,
          ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
          ownerOrgName: null,
          activeDeployments: Number(active?.c ?? 0),
        };
      }),
    );
    return reply.send({ templates: enriched, categories: CATEGORIES });
  });

  // ---- Detail ----
  app.get('/v1/templates/:slug', async (req, reply) => {
    const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' });

    const [row] = await db
      .select({
        template: templates,
        ownerName: users.name,
        ownerEmail: users.email,
        ownerOrgName: organizations.name,
      })
      .from(templates)
      .leftJoin(users, eq(users.id, templates.ownerUserId))
      .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
      .where(eq(templates.slug, parsed.data.slug))
      .limit(1);
    if (!row) return reply.code(404).send({ error: 'not_found' });
    if (row.template.status === 'takedown') {
      // Takedown is an admin decision — sealed for everyone, including the owner.
      return reply.code(410).send({
        error: 'taken_down',
        reason: row.template.takedownReason,
      });
    }
    if (row.template.status !== 'public') {
      // hidden / draft — visible only to the owner (optional auth check).
      const session = await getSession(req.cookies['bmm_session']);
      const isOwner = session != null && session.userId === row.template.ownerUserId;
      if (!isOwner) {
        return reply.code(404).send({ error: 'not_found' });
      }
    }

    const [active] = await db
      .select({ c: count() })
      .from(mcpServers)
      .where(and(eq(mcpServers.templateId, row.template.id), eq(mcpServers.status, 'live')));

    return reply.send({
      template: {
        ...row.template,
        ownerName: row.ownerName ?? row.ownerEmail?.split('@')[0] ?? null,
        ownerOrgName: row.ownerOrgName,
        activeDeployments: Number(active?.c ?? 0),
      },
    });
  });

  // ---- Fork (returns previewId so wizard can complete with user's secrets) ----
  app.post('/v1/templates/:slug/fork', { preHandler: requireAuth }, async (req, reply) => {
    const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
    const parsed = Params.safeParse(req.params);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' });

    const [template] = await db
      .select()
      .from(templates)
      .where(eq(templates.slug, parsed.data.slug))
      .limit(1);
    if (!template || template.status !== 'public') {
      return reply.code(404).send({ error: 'not_found' });
    }

    // Reconstruct a GeneratorSpec from the template that the worker can render
    const toolsRaw = template.toolsSchema as Array<{
      name: string;
      description: string;
      inputSchema: Record<string, unknown>;
    }>;
    const fullSpec = {
      name: template.title,
      description: template.shortDescription,
      tools: toolsRaw.map((t) => ({
        name: t.name,
        description: t.description,
        inputSchema: t.inputSchema as Record<string, never>,
        // The actual implementation is in template.generatedCode, but we have to
        // satisfy the per-tool GeneratorSpec shape. The worker uses the
        // cached spec only for non-impl fields; render extracts code from full spec.
        // For fork we ship a placeholder impl — the worker will see previewId
        // and would normally regenerate. We need a different mechanism:
        // we use the generatedCode field directly through the cache.
        implementation: '// implementation provided by template',
      })),
      resources: [],
      prompts: [],
      requiredSecrets: (template.requiredSecrets as Array<{ key: string }>).map((s) => s.key),
      scopes: template.scopes as string[],
      dependencies: {},
    };

    const validation = GeneratorSpec.safeParse(fullSpec);
    if (!validation.success) {
      return reply
        .code(500)
        .send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
    }
    const previewId = await cacheSpec(validation.data);
    // Persist the pre-rendered code under the same previewId so the worker uses it
    // verbatim instead of re-rendering (which would lose the template's per-tool impls).
    await cachePrebuiltCode(previewId, template.generatedCode);
    // Record the fork→template link so /v1/servers can verify the user actually
    // went through this endpoint before accepting templateId.
    await setForkRef(previewId, template.id);

    return reply.send({
      previewId,
      templateId: template.id,
      template: {
        slug: template.slug,
        title: template.title,
        shortDescription: template.shortDescription,
        tools: toolsRaw,
        requiredSecrets: template.requiredSecrets,
      },
    });
  });

  // ---- Admin moderation ----
  app.get('/v1/admin/templates', { preHandler: requireAdmin }, async (_req, reply) => {
    const rows = await db
      .select({
        template: templates,
        ownerEmail: users.email,
        ownerOrgName: organizations.name,
      })
      .from(templates)
      .leftJoin(users, eq(users.id, templates.ownerUserId))
      .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
      .orderBy(desc(templates.createdAt));

    const enriched = await Promise.all(
      rows.map(async (r) => {
        const [active] = await db
          .select({ c: count() })
          .from(mcpServers)
          .where(and(eq(mcpServers.templateId, r.template.id), eq(mcpServers.status, 'live')));
        return {
          ...r.template,
          ownerEmail: r.ownerEmail,
          ownerOrgName: r.ownerOrgName,
          activeDeployments: Number(active?.c ?? 0),
        };
      }),
    );
    return reply.send({ templates: enriched });
  });

  app.patch('/v1/admin/templates/:id', { preHandler: requireAdmin }, async (req, reply) => {
    const Params = z.object({ id: z.string().uuid() });
    const Body = z.object({
      verified: z.boolean().optional(),
      status: z.enum(['draft', 'public', 'hidden', 'takedown']).optional(),
      takedownReason: z.string().max(500).nullable().optional(),
    });
    const p = Params.safeParse(req.params);
    const b = Body.safeParse(req.body);
    if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });

    await db
      .update(templates)
      .set({ ...b.data, updatedAt: new Date() })
      .where(eq(templates.id, p.data.id));

    // Takedown cascade: stop every fork's container, then mark them paused.
    // Just flipping the DB status leaves the container running and serving
    // traffic; we MUST hard-stop them or the takedown is cosmetic.
    let stoppedContainers = 0;
    if (b.data.status === 'takedown') {
      const forkedServers = await db
        .select({ id: mcpServers.id, containerId: mcpServers.containerId, slug: mcpServers.slug })
        .from(mcpServers)
        .where(eq(mcpServers.templateId, p.data.id));
      for (const fork of forkedServers) {
        if (fork.containerId) {
          const result = await stopContainer(fork.containerId, fork.slug);
          if (result.ok) stoppedContainers++;
          else
            app.log.warn(
              { containerId: fork.containerId, detail: result.detail },
              'takedown: stop failed',
            );
        }
      }
      await db
        .update(mcpServers)
        .set({
          status: 'paused',
          containerId: null,
          publicUrl: null,
          hostPort: null,
          updatedAt: new Date(),
        })
        .where(eq(mcpServers.templateId, p.data.id));
    }

    await audit({
      orgId: req.user!.orgId,
      userId: req.user!.userId,
      action: 'admin.template.update',
      resourceType: 'template',
      resourceId: p.data.id,
      metadata: { ...b.data, stoppedContainers },
      ipAddress: req.ip,
    });
    return reply.send({ ok: true, stoppedContainers });
  });

  // unused-import guard
  void gte;
  void sql;
}
