/**
 * KB (Knowledge Base) staff API routes — kb-module + kb-article-editor.
 * Mounted at /api/kb in apps/zync-api/src/routes/index.ts.
 *
 * All routes require an authenticated user session and the 'kb' module enabled.
 *
 * GET    /api/kb/spaces                    → KbSpace[] (kb:read)
 * POST   /api/kb/spaces                    → KbSpace   (kb:write)
 * PATCH  /api/kb/spaces/:id               → KbSpace   (kb:write)
 * DELETE /api/kb/spaces/:id               → { ok: true } (kb:write)
 *
 * GET    /api/kb/spaces/:id/articles       → KbArticleNode[] (kb:read)
 * POST   /api/kb/articles                  → KbArticle (kb:write)
 * GET    /api/kb/articles/:id              → KbArticle (kb:read)
 * PATCH  /api/kb/articles/:id             → KbArticle (kb:write)
 * DELETE /api/kb/articles/:id             → { ok: true } (kb:delete, soft-delete)
 * PUT    /api/kb/articles/:id/view        → 204 (kb:read, fire-and-forget)
 *
 * POST   /api/kb/articles/:id/publish     → KbArticle (kb:publish)
 * POST   /api/kb/articles/:id/unpublish   → KbArticle (kb:publish)
 * GET    /api/kb/workflow-settings        → { kb_require_review, kb_versioning_enabled } (kb:read)
 * POST   /api/kb/articles/:id/duplicate   → KbArticle (kb:write)
 * GET    /api/kb/images/:key{.+}          → inline image proxy (public; optional session)
 * POST   /api/kb/articles/:id/images      → { url } (kb:write, multipart, max 5 MB)
 *
 * POST   /api/kb/articles/:id/attachments → KbAttachment (kb:write, multipart)
 * GET    /api/kb/articles/:id/attachments → KbAttachment[] (kb:read)
 * GET    /api/kb/attachments/:id/url      → { url, expiresAt } (kb:read)
 *
 * GET    /api/kb/search?q=               → KbSearchResult[] (kb:read)
 */
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { z } from 'zod'
import {
  createDb,
  kbTenantQuery,
  kbPortalQuery,
  getEnabledModuleIds,
  getKbSettings,
  assertTenantOwnsKbSpace,
  assertTenantOwnsKbArticle,
  invalidTenantReferenceBody,
  getLatestRejectionFeedback,
  KbSpaceHasArticlesError,
} from '@zync/db/queries'
import type { AppEnv } from '../types'
import { requirePermission } from '../middleware/guards'
import { authMiddleware } from '../middleware/auth'
import { evaluateUserSessionRow } from '../middleware/session-guard'
import { requireModuleEnabled } from '../middleware/require-module-enabled'
import {
  hashToken,
  hasPermission,
  isTokenBlocklisted,
  findActivePortalSession,
  parsePortalCookie,
  parseSessionCookie,
  verifyPortalToken,
  verifySession,
} from '@zync/auth'
import type { SessionPayload } from '@zync/types'
import { getUserVersion } from '../middleware/user-version'
import {
  createSpaceSchema,
  updateSpaceSchema,
  createArticleSchema,
  kbSearchQuerySchema,
  serializeKbAttachment,
  editorPatchSchema,
  validateTiptapContent,
  KB_IMAGE_MIME_ALLOWLIST,
  MAX_KB_IMAGE_BYTES,
} from '@zync/types'
import { assertAllowedFileType, kbR2Key, putKbObject, signKbUrl } from '../lib/kb-storage'
import { fractionalIndexAfter } from '../lib/fractional-index'
import { upsertKbVector, deleteKbVector } from '../lib/kb-index'
import {
  performPublishTransition,
  performUnpublishTransition,
  validatePublishTransition,
} from '../lib/kb-publish-transitions'
import { kbVersionsRouter } from './kb/versions'

// ── Max upload size: 25 MB ────────────────────────────────────────────────────
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024

const kbRouter = new Hono<AppEnv>()

const KB_INLINE_IMAGE_KEY_RE = /^kb\/([^/]+)\/images\/([^/]+)\//

function extractStaffToken(c: Context): string | null {
  const cookie = parseSessionCookie(c.req.header('Cookie'))
  if (cookie) return cookie
  const auth = c.req.header('Authorization')
  if (auth && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
  return null
}

async function tryResolveStaffSession(c: Context): Promise<SessionPayload | null> {
  const token = extractStaffToken(c)
  if (!token) return null

  const accessTokenHash = await hashToken(token)
  const [legacyBlocked, canonicalBlocked] = await Promise.all([
    c.env.KV.get(`blocklist:${accessTokenHash}`),
    isTokenBlocklisted(c.env.RATELIMIT_KV, accessTokenHash),
  ])
  if (legacyBlocked || canonicalBlocked) return null

  try {
    const session = await verifySession(token, c.env.JWT_SECRET)
    if (session.type !== 'user' || !session.tid) return null
    const currentVersion = await getUserVersion(c.env, session.sub)
    if (session.v < currentVersion) return null

    const db = createDb(c.env)
    const evaluation = await evaluateUserSessionRow(db, session.tid, accessTokenHash)
    if (!evaluation.ok) return null

    return session
  } catch {
    return null
  }
}

async function tryResolvePortalContext(
  c: Context,
): Promise<{ tenantId: string; customerId: string } | null> {
  const token = parsePortalCookie(c.req.header('Cookie'))
  if (!token) return null

  try {
    const payload = await verifyPortalToken(token, c.env.JWT_SECRET)
    if (payload.role !== 'portal_customer') return null

    const db = createDb(c.env)
    const tokenHash = await hashToken(token)
    const activeSession = await findActivePortalSession(db, tokenHash)
    if (!activeSession || activeSession.tenantId !== payload.tenantId) return null

    return {
      tenantId: activeSession.tenantId,
      customerId: activeSession.customerId,
    }
  } catch {
    return null
  }
}

async function canReadKbInlineImage(
  c: Context,
  tenantId: string,
  articleId: string,
): Promise<{ allowed: boolean; isPublicPublished: boolean }> {
  const db = createDb(c.env)
  const q = kbTenantQuery(db, tenantId)
  const article = await q.getArticleById(articleId)
  if (!article) return { allowed: false, isPublicPublished: false }

  const space = await q.getSpaceById(article.spaceId)
  if (!space) return { allowed: false, isPublicPublished: false }

  const isPublicPublished = space.isPublic && article.status === 'PUBLISHED'
  if (isPublicPublished) {
    return { allowed: true, isPublicPublished: true }
  }

  const staffSession = await tryResolveStaffSession(c)
  if (
    staffSession &&
    staffSession.tid === tenantId &&
    hasPermission(staffSession.permissions, 'kb:read')
  ) {
    const enabledIds = await getEnabledModuleIds(db, tenantId)
    if (enabledIds.includes('kb')) {
      return { allowed: true, isPublicPublished: false }
    }
  }

  const portal = await tryResolvePortalContext(c)
  if (portal && portal.tenantId === tenantId) {
    const portalArticle = await kbPortalQuery(db, tenantId, portal.customerId).getPublishedArticleById(
      articleId,
    )
    if (portalArticle) {
      return { allowed: true, isPublicPublished: false }
    }
  }

  return { allowed: false, isPublicPublished: false }
}

// GET /api/kb/images/:key — public proxy; access mirrors article read paths
kbRouter.get('/images/:key{.+}', async (c) => {
  const key = c.req.param('key')
  if (
    key.includes('..') ||
    key.includes('\\') ||
    key.startsWith('/') ||
    !key.startsWith('kb/')
  ) {
    return c.json({ error: 'Forbidden' }, 403)
  }

  const parsed = key.match(KB_INLINE_IMAGE_KEY_RE)
  if (!parsed) {
    return c.json({ error: 'Forbidden' }, 403)
  }

  const tenantId = parsed[1]!
  const articleId = parsed[2]!
  const access = await canReadKbInlineImage(c, tenantId, articleId)
  if (!access.allowed) {
    return c.json({ error: 'Forbidden' }, 403)
  }

  const obj = await c.env.STORAGE.get(key)
  if (!obj) {
    return c.json({ error: 'Not found' }, 404)
  }

  const headers = new Headers()
  headers.set('Content-Type', obj.httpMetadata?.contentType ?? 'application/octet-stream')
  headers.set('X-Content-Type-Options', 'nosniff')
  headers.set(
    'Cache-Control',
    access.isPublicPublished
      ? 'public, max-age=300, stale-while-revalidate=60'
      : 'private, max-age=300',
  )
  if (obj.httpEtag) {
    headers.set('etag', obj.httpEtag)
  }

  return new Response(obj.body, { headers })
})

// All other KB routes require auth + module enabled
kbRouter.use('*', authMiddleware)
kbRouter.use('*', requireModuleEnabled('kb'))

const kbIdParamSchema = z.object({ id: z.string().uuid() })

async function validateKbIdParam(c: Context, next: Next) {
  const parsed = kbIdParamSchema.safeParse({ id: c.req.param('id') })
  if (!parsed.success) return c.json({ error: 'Invalid id' }, 400)
  await next()
}

for (const pattern of [
  '/spaces/:id',
  '/spaces/:id/*',
  '/articles/:id',
  '/articles/:id/*',
  '/attachments/:id/*',
]) {
  kbRouter.use(pattern, validateKbIdParam)
}

// ── Spaces ────────────────────────────────────────────────────────────────────

// GET /api/kb/spaces
kbRouter.get('/spaces', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const spaces = await kbTenantQuery(db, session.tid).listSpaces()

  // Group: internal first, then vault
  const internal = spaces.filter((s) => s.type === 'internal')
  const vaults = spaces.filter((s) => s.type === 'vault')

  return c.json([...internal, ...vaults], 200)
})

// GET /api/kb/workflow-settings — editor-facing KB workflow flags (kb:read)
kbRouter.get('/workflow-settings', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const settings = await getKbSettings(db, session.tid)
  return c.json(
    {
      kb_require_review: settings.kb_require_review,
      kb_versioning_enabled: settings.kb_versioning_enabled,
      kb_default_space_id: settings.kb_default_space_id,
    },
    200,
  )
})

// POST /api/kb/spaces
kbRouter.post('/spaces', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = createSpaceSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const data = parsed.data
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  // If vault, verify customer_id belongs to tenant
  if (data.type === 'vault' && data.customer_id) {
    const valid = await q.verifyCustomerBelongsToTenant(data.customer_id)
    if (!valid) {
      return c.json({ error: 'Customer not found for this tenant' }, 422)
    }
  }

  const space = await q.createSpace({
    name: data.name,
    slug: data.slug,
    type: data.type,
    customerId: data.customer_id ?? null,
    icon: data.icon ?? null,
    isPublic: data.is_public,
    description: data.description ?? null,
    createdBy: session.sub,
  })

  return c.json(space, 201)
})

// PATCH /api/kb/spaces/:id
kbRouter.patch('/spaces/:id', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = updateSpaceSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const data = parsed.data
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  if (data.customer_id !== undefined && data.customer_id !== null) {
    const valid = await q.verifyCustomerBelongsToTenant(data.customer_id)
    if (!valid) {
      return c.json({ error: 'Customer not found for this tenant' }, 422)
    }
  }

  try {
    const space = await q.updateSpace(c.req.param('id'), {
      ...(data.name !== undefined && { name: data.name }),
      ...(data.slug !== undefined && { slug: data.slug }),
      ...(data.customer_id !== undefined && { customerId: data.customer_id ?? null }),
      ...(data.icon !== undefined && { icon: data.icon ?? null }),
      ...(data.is_public !== undefined && { isPublic: data.is_public }),
      ...(data.description !== undefined && { description: data.description ?? null }),
    })
    return c.json(space, 200)
  } catch (err) {
    if (err instanceof Error && err.message === 'Space not found') {
      return c.json({ error: 'Not found' }, 404)
    }
    throw err
  }
})

// DELETE /api/kb/spaces/:id
kbRouter.delete('/spaces/:id', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const spaceId = c.req.param('id')
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  try {
    await q.deleteSpace(spaceId)
  } catch (err) {
    if (err instanceof KbSpaceHasArticlesError) {
      return c.json(
        { error: err.message, code: 'SPACE_NOT_EMPTY', articleCount: err.articleCount },
        409,
      )
    }
    if (err instanceof Error && err.message === 'Space not found') {
      return c.json({ error: 'Not found' }, 404)
    }
    throw err
  }

  return c.json({ ok: true }, 200)
})

// ── Article tree ──────────────────────────────────────────────────────────────

// GET /api/kb/spaces/:id/articles
kbRouter.get('/spaces/:id/articles', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const tree = await kbTenantQuery(db, session.tid).listArticleTree(c.req.param('id'))
  return c.json(tree, 200)
})

// ── Articles CRUD ─────────────────────────────────────────────────────────────

// POST /api/kb/articles
kbRouter.post('/articles', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = createArticleSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const data = parsed.data
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  if (!(await assertTenantOwnsKbSpace(db, session.tid, data.space_id))) {
    return c.json(invalidTenantReferenceBody('space_id'), 400)
  }
  if (!(await assertTenantOwnsKbArticle(db, session.tid, data.parent_id ?? null))) {
    return c.json(invalidTenantReferenceBody('parent_id'), 400)
  }

  // Compute fractional position (after last sibling)
  const lastPosition = await q.getLastSiblingPosition(data.space_id, data.parent_id ?? null)
  const position = fractionalIndexAfter(lastPosition)

  let article
  try {
    article = await q.createArticle({
      spaceId: data.space_id,
      parentId: data.parent_id ?? null,
      title: data.title,
      slug: data.slug,
      content: data.content,
      status: 'DRAFT',
      position: String(position),
      publishedAt: null,
      createdBy: session.sub,
      updatedBy: null,
    })
  } catch (err) {
    if (err instanceof Error && err.message.includes('maximum tree depth')) {
      return c.json({ error: err.message }, 400)
    }
    throw err
  }

  // Upsert Vectorize (fire-and-forget)
  c.executionCtx.waitUntil(upsertKbVector(c.env, { tenantId: session.tid, article }))

  return c.json(article, 201)
})

// GET /api/kb/articles/:id
kbRouter.get('/articles/:id', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const article = await kbTenantQuery(db, session.tid).getArticleById(c.req.param('id'))
  if (!article) return c.json({ error: 'Not found' }, 404)

  const latestRejection =
    article.status === 'DRAFT'
      ? await getLatestRejectionFeedback(db, session.tid, article.id)
      : null

  return c.json(latestRejection ? { ...article, latestRejection } : article, 200)
})

// PATCH /api/kb/articles/:id
// Supports all editor patch fields: content, title, position, parent_id,
// meta_title, meta_description, slug, status.
kbRouter.patch('/articles/:id', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = editorPatchSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const data = parsed.data

  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  if (
    data.parent_id !== undefined &&
    !(await assertTenantOwnsKbArticle(db, session.tid, data.parent_id))
  ) {
    return c.json(invalidTenantReferenceBody('parent_id'), 400)
  }

  // Validate Tiptap content if provided
  let validatedContent: Record<string, unknown> | undefined
  if (data.content !== undefined) {
    try {
      validatedContent = validateTiptapContent(data.content) as Record<string, unknown>
    } catch {
      return c.json({ error: 'Invalid Tiptap content structure' }, 422)
    }
  }

  const articleId = c.req.param('id')

  // Status transitions require kb:publish and publish review-gate BEFORE any meta write.
  if (data.status !== undefined) {
    if (!hasPermission(session.permissions, 'kb:publish')) {
      return c.json({ error: 'Forbidden' }, 403)
    }
    if (data.status === 'PUBLISHED') {
      const preflight = await validatePublishTransition({
        db,
        tenantId: session.tid,
        articleId,
        session,
      })
      if (!preflight.ok) {
        return c.json(preflight.body, preflight.status)
      }
    }
  }

  try {
    const article = await q.updateArticleMeta(
      articleId,
      {
        title: data.title,
        slug: data.slug,
        parentId: data.parent_id,
        metaTitle: data.meta_title,
        metaDescription: data.meta_description,
        content: validatedContent,
        position: data.position,
      },
      session.sub,
    )

    let finalArticle = article
    if (data.status !== undefined) {
      if (data.status === 'PUBLISHED') {
        const publishResult = await performPublishTransition({
          db,
          tenantId: session.tid,
          articleId,
          session,
        })
        if (!publishResult.ok) {
          return c.json(publishResult.body, publishResult.status)
        }
        finalArticle = publishResult.article
      } else {
        const unpublishResult = await performUnpublishTransition({
          db,
          tenantId: session.tid,
          articleId,
          userId: session.sub,
        })
        if (!unpublishResult.ok) {
          return c.json(unpublishResult.body, unpublishResult.status)
        }
        finalArticle = unpublishResult.article
      }
    }

    // Re-index vector (fire-and-forget)
    c.executionCtx.waitUntil(upsertKbVector(c.env, { tenantId: session.tid!, article: finalArticle }))

    return c.json(finalArticle, 200)
  } catch (err) {
    if (err instanceof Error && err.message === 'Article not found') {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('maximum tree depth')) {
      return c.json({ error: err.message }, 400)
    }
    throw err
  }
})

// DELETE /api/kb/articles/:id — soft delete (sets deleted_at, cascades to subtree)
kbRouter.delete('/articles/:id', requirePermission('kb:delete'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const articleId = c.req.param('id')
  const db = createDb(c.env)

  await kbTenantQuery(db, session.tid).softDeleteArticle(articleId, session.sub)

  // Remove vector (fire-and-forget)
  c.executionCtx.waitUntil(deleteKbVector(c.env, session.tid, articleId))

  return c.json({ ok: true }, 200)
})

// PUT /api/kb/articles/:id/view (fire-and-forget view count increment)
kbRouter.put('/articles/:id/view', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  c.executionCtx.waitUntil(
    kbTenantQuery(db, session.tid).incrementViewCount(c.req.param('id')),
  )

  return new Response(null, { status: 204 })
})

// ── Publish / Unpublish / Duplicate / Images ──────────────────────────────────

// POST /api/kb/articles/:id/publish
kbRouter.post('/articles/:id/publish', requirePermission('kb:publish'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const articleId = c.req.param('id')
  const db = createDb(c.env)

  const publishResult = await performPublishTransition({
    db,
    tenantId: session.tid,
    articleId,
    session,
  })
  if (!publishResult.ok) {
    return c.json(publishResult.body, publishResult.status)
  }

  c.executionCtx.waitUntil(
    upsertKbVector(c.env, { tenantId: session.tid!, article: publishResult.article }),
  )
  return c.json(publishResult.article, 200)
})

// POST /api/kb/articles/:id/unpublish
kbRouter.post('/articles/:id/unpublish', requirePermission('kb:publish'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const articleId = c.req.param('id')

  const unpublishResult = await performUnpublishTransition({
    db,
    tenantId: session.tid,
    articleId,
    userId: session.sub,
  })
  if (!unpublishResult.ok) {
    return c.json(unpublishResult.body, unpublishResult.status)
  }

  c.executionCtx.waitUntil(
    upsertKbVector(c.env, { tenantId: session.tid!, article: unpublishResult.article }),
  )
  return c.json(unpublishResult.article, 200)
})

// POST /api/kb/articles/:id/duplicate
kbRouter.post('/articles/:id/duplicate', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  try {
    const article = await q.duplicateArticle(c.req.param('id'), session.sub)
    c.executionCtx.waitUntil(upsertKbVector(c.env, { tenantId: session.tid!, article }))
    return c.json(article, 201)
  } catch (err) {
    if (err instanceof Error && err.message === 'Article not found') {
      return c.json({ error: 'Not found' }, 404)
    }
    throw err
  }
})

// POST /api/kb/articles/:id/images
// Uploads an inline editor image to R2 and returns a stable access-controlled proxy URL.
// Max 5 MB; only jpg/png/webp/gif allowed; SVG rejected.
kbRouter.post('/articles/:id/images', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const articleId = c.req.param('id')
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  // Verify article exists and belongs to tenant
  const article = await q.getArticleById(articleId)
  if (!article) return c.json({ error: 'Article not found' }, 404)

  // Parse multipart form
  let formData: FormData
  try {
    formData = await c.req.formData()
  } catch {
    return c.json({ error: 'Expected multipart/form-data' }, 400)
  }

  const fileField = formData.get('file') as unknown
  if (!fileField || !(fileField instanceof File)) {
    return c.json({ error: 'file field is required' }, 400)
  }

  const file = fileField as File
  const mime = file.type

  // Validate MIME — only images (narrower than attachment allowlist)
  if (!(KB_IMAGE_MIME_ALLOWLIST as readonly string[]).includes(mime)) {
    return c.json({ error: `File type not permitted for inline images: ${mime}` }, 415)
  }

  // Validate size
  if (file.size > MAX_KB_IMAGE_BYTES) {
    return c.json({ error: `File too large (max ${MAX_KB_IMAGE_BYTES / 1024 / 1024} MB)` }, 413)
  }

  const buffer = await file.arrayBuffer()

  // Build R2 key under public images prefix
  const imageId = crypto.randomUUID()
  const safeFilename = file.name.replace(/[/\\]/g, '_').slice(0, 200)
  const r2Key = `kb/${session.tid}/images/${articleId}/${imageId}/${safeFilename}`

  await putKbObject(c.env, r2Key, buffer, mime)

  const url = `/api/kb/images/${r2Key}`

  return c.json({ url }, 201)
})

// ── Attachments ───────────────────────────────────────────────────────────────

// POST /api/kb/articles/:id/attachments
kbRouter.post('/articles/:id/attachments', requirePermission('kb:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const articleId = c.req.param('id')
  const db = createDb(c.env)
  const q = kbTenantQuery(db, session.tid)

  // Verify article exists and belongs to tenant
  const article = await q.getArticleById(articleId)
  if (!article) return c.json({ error: 'Article not found' }, 404)

  // Parse multipart form
  let formData: FormData
  try {
    formData = await c.req.formData()
  } catch {
    return c.json({ error: 'Expected multipart/form-data' }, 400)
  }

  const fileField = formData.get('file') as unknown
  if (!fileField || !(fileField instanceof File)) {
    return c.json({ error: 'file field is required' }, 400)
  }

  const file = fileField as File

  // Size check
  if (file.size > MAX_UPLOAD_BYTES) {
    return c.json({ error: 'File too large (max 25 MB)' }, 413)
  }

  // MIME type check
  const mime = file.type || 'application/octet-stream'
  try {
    assertAllowedFileType(mime)
  } catch (err) {
    return c.json({ error: (err as Error).message }, 415)
  }

  // Generate attachment ID and R2 key
  const attachmentId = crypto.randomUUID()
  const r2Key = kbR2Key(session.tid, articleId, attachmentId, file.name)

  // Upload to R2
  const buffer = await file.arrayBuffer()
  await putKbObject(c.env, r2Key, buffer, mime)

  // Insert DB record
  const row = await q.createAttachment({
    articleId,
    filename: file.name,
    r2Key,
    fileType: mime,
    fileSizeBytes: file.size,
    createdBy: session.sub,
  })

  return c.json(serializeKbAttachment(row), 201)
})

// GET /api/kb/articles/:id/attachments
kbRouter.get('/articles/:id/attachments', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const attachments = await kbTenantQuery(db, session.tid).listAttachments(c.req.param('id'))
  return c.json(attachments, 200)
})

// GET /api/kb/attachments/:id/url
kbRouter.get('/attachments/:id/url', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = createDb(c.env)
  const attachment = await kbTenantQuery(db, session.tid).getAttachment(c.req.param('id'))
  if (!attachment) return c.json({ error: 'Not found' }, 404)

  const ttlSeconds = 3600
  const url = await signKbUrl(c.env, attachment.r2Key, ttlSeconds)
  const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString()

  return c.json({ url, expiresAt }, 200)
})

// ── Search ────────────────────────────────────────────────────────────────────

// GET /api/kb/search?q=
kbRouter.get('/search', requirePermission('kb:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = kbSearchQuerySchema.safeParse(c.req.query())
  if (!parsed.success) {
    return c.json({ error: 'Invalid query', issues: parsed.error.issues }, 400)
  }

  const { q } = parsed.data

  // Embed query via Workers AI
  let queryValues: number[] | null = null
  try {
    const result = await c.env.AI.run(
      '@cf/baai/bge-small-en-v1.5' as Parameters<typeof c.env.AI.run>[0],
      { text: [q] } as Parameters<typeof c.env.AI.run>[1],
    )
    const data =
      (result as { data?: number[][] }).data?.[0] ??
      (result as { result?: { data?: number[][] } }).result?.data?.[0]
    queryValues = data ?? null
  } catch {
    return c.json({ error: 'Embedding failed' }, 500)
  }

  if (!queryValues) {
    return c.json([], 200)
  }

  // Query Vectorize
  const queryResult = await c.env.VECTORIZE.query(queryValues, {
    namespace: `tenant:${session.tid}`,
    topK: 10,
    filter: { source: 'kb' } as VectorizeQueryOptions['filter'],
    returnMetadata: 'all',
  })

  const matches = queryResult.matches ?? []

  // Resolve article metadata for matched IDs
  const db = createDb(c.env)
  const q2 = kbTenantQuery(db, session.tid)

  const results = await Promise.all(
    matches.map(async (match) => {
      const meta = match.metadata as Record<string, string> | undefined
      if (!meta?.articleId || !meta?.spaceId) return null

      const [article, space] = await Promise.all([
        q2.getArticleById(meta.articleId),
        q2.getSpaceById(meta.spaceId),
      ])

      if (!article || !space) return null

      return {
        articleId: article.id,
        spaceId: article.spaceId,
        spaceSlug: space.slug,
        articleSlug: article.slug,
        title: article.title,
        score: match.score,
      }
    }),
  )

  const filtered = results.filter(Boolean)
  return c.json(filtered, 200)
})

// ── kb-versioning routes (Business+ tier gated) ───────────────────────────────
kbRouter.route('', kbVersionsRouter)

export { kbRouter }
