import { createHash } from "node:crypto"
import { and, asc, count, desc, eq, inArray, isNull, like, or } from "drizzle-orm"
import { z } from "zod"
import { callChatCompletion } from "@/server/ai/generation"
import { getDb } from "@/server/db/client"
import { knowledgeBaseArticlePath } from "@/lib/dashboard-routes"
import { normalizeS4ObjectKey, normalizeS4ObjectUrl } from "@/lib/s4-url"
import {
    knowledgeBaseAgentArtifacts,
    knowledgeBaseAgentMessages,
    knowledgeBaseAgentRuns,
    knowledgeBaseAgentSteps,
    knowledgeBaseAgentThreads,
    knowledgeBaseArticles,
    knowledgeBaseNodes,
    knowledgeBases,
    knowledgeBaseWikiEventLogs,
    knowledgeBaseWikiLinks,
    knowledgeBaseWikiPages,
    knowledgeBaseWikiPatches,
    knowledgeBaseWikiSourceRefs,
    knowledgeBaseWikiTreeNodes,
    type KnowledgeBaseAgentArtifactRecord,
    type KnowledgeBaseAgentThreadRecord,
    type KnowledgeBaseArticleRecord,
    type KnowledgeBaseRecord,
    type KnowledgeBaseWikiPageRecord,
    type KnowledgeBaseWikiPatchRecord,
} from "@/server/db/schema"
import { badRequest, notFound } from "@/server/http/response"
import { scoreSearchFields } from "@/server/kb/search-terms"
import {
    buildArticleTree,
    embedKnowledgeBaseTreeNodesBestEffort,
    getKbWikiEmbeddingStatus,
} from "@/server/kb/wiki-tree"

type Db = ReturnType<typeof getDb>

export type WikiPageKind = "index" | "source" | "concept" | "entity" | "comparison" | "answer" | "log"
export type WikiPatchStatus = "PENDING" | "APPLIED" | "REJECTED"
export type AgentMediaKind = "image" | "video" | "audio" | "file"

export type AgentImageReference = {
    id: string
    kind: AgentMediaKind
    alt: string
    src: string
    objectKey: string | null
    filename: string
    sourceArticleId?: string
    sourceArticleTitle?: string
}

export const idSchema = z.union([z.string(), z.number()]).transform((value, ctx) => {
    const raw = String(value).trim()
    if (!/^\d+$/.test(raw)) {
        ctx.addIssue({ code: "custom", message: "ID 必须是正整数" })
        return z.NEVER
    }
    return Number(raw)
})

export const knowledgeBaseIdInputSchema = z.object({
    knowledgeBaseId: idSchema,
})

export const optionalKnowledgeBaseIdInputSchema = z.object({
    knowledgeBaseId: idSchema.optional().nullable(),
})

export const wikiIngestInputSchema = knowledgeBaseIdInputSchema.extend({
    articleIds: z.array(idSchema).optional(),
    forceRebuild: z.boolean().optional().default(false),
})

export const wikiPageDetailInputSchema = knowledgeBaseIdInputSchema.extend({
    pageKey: z.string().trim().min(1).max(200),
})

export const wikiTreeInputSchema = knowledgeBaseIdInputSchema.extend({
    articleId: idSchema.optional(),
})

export const wikiPatchDecisionInputSchema = knowledgeBaseIdInputSchema.extend({
    patchId: idSchema,
})

export const agentThreadInputSchema = knowledgeBaseIdInputSchema.extend({
    threadId: idSchema.optional(),
})

export const qaThreadDetailInputSchema = z.object({
    threadId: idSchema,
})

export const qaThreadDeleteInputSchema = z.object({
    threadId: idSchema,
})

export const qaThreadDeleteManyInputSchema = z.object({
    threadIds: z.array(idSchema).min(1).max(200),
})

export const qaThreadListInputSchema = z.object({
    cursor: z.number().int().nonnegative().optional(),
    limit: z.number().int().positive().max(100).optional(),
    q: z.string().trim().max(120).optional(),
    scope: z.string().optional(),
})

export const agentThreadCreateInputSchema = knowledgeBaseIdInputSchema.extend({
    title: z.string().trim().max(120).optional(),
})

export const qaThreadCreateInputSchema = z.object({
    knowledgeBaseId: idSchema.optional().nullable(),
    title: z.string().trim().max(120).optional(),
})

export const agentArtifactCreateInputSchema = knowledgeBaseIdInputSchema.extend({
    threadId: idSchema,
    runId: idSchema.optional(),
    artifactType: z.string().trim().min(1).max(80),
    title: z.string().trim().min(1).max(200),
    contentMd: z.string().optional().nullable(),
    payload: z.unknown().optional(),
})

const llmArticleWikiSchema = z.object({
    summary: z.string().optional(),
    keyPoints: z.array(z.string()).optional(),
    entities: z.array(z.string()).optional(),
    questions: z.array(z.string()).optional(),
})

interface ArticleWikiDraft {
    summary: string
    keyPoints: string[]
    entities: string[]
    questions: string[]
}

export function formatDate(value: Date | string | null | undefined): string | null {
    if (!value) return null
    return value instanceof Date ? value.toISOString() : new Date(value).toISOString()
}

export function stableHash(value: string) {
    return createHash("sha256").update(value).digest("hex")
}

export function normalizePageKey(input: string) {
    const key = input
        .trim()
        .toLowerCase()
        .replace(/[\s/\\#?&=]+/g, "-")
        .replace(/[^a-z0-9\u4e00-\u9fa5._-]+/g, "")
        .replace(/-+/g, "-")
        .replace(/^-|-$/g, "")

    return key || `page-${stableHash(input).slice(0, 12)}`
}

export function toWikiPageResponse(page: KnowledgeBaseWikiPageRecord) {
    return {
        id: String(page.id),
        knowledgeBaseId: String(page.knowledgeBaseId),
        pageKey: page.pageKey,
        title: page.title,
        kind: page.kind as WikiPageKind,
        contentMd: page.contentMd,
        frontmatter: parseJsonObject(page.frontmatterJson),
        summary: page.summary,
        contentHash: page.contentHash,
        version: page.version,
        archivedAt: formatDate(page.archivedAt),
        createdAt: formatDate(page.createdAt),
        updatedAt: formatDate(page.updatedAt),
    }
}

export function toWikiPatchResponse(patch: KnowledgeBaseWikiPatchRecord) {
    return {
        id: String(patch.id),
        knowledgeBaseId: String(patch.knowledgeBaseId),
        threadId: patch.threadId == null ? null : String(patch.threadId),
        runId: patch.runId == null ? null : String(patch.runId),
        pageKey: patch.pageKey,
        title: patch.title,
        operation: patch.operation,
        status: patch.status as WikiPatchStatus,
        beforeContentMd: patch.beforeContentMd,
        proposedContentMd: patch.proposedContentMd,
        diffText: patch.diffText,
        reason: patch.reason,
        appliedAt: formatDate(patch.appliedAt),
        createdAt: formatDate(patch.createdAt),
        updatedAt: formatDate(patch.updatedAt),
    }
}

export function toAgentThreadResponse(thread: KnowledgeBaseAgentThreadRecord, knowledgeBaseName?: string | null) {
    return {
        id: String(thread.id),
        knowledgeBaseId: thread.knowledgeBaseId == null ? null : String(thread.knowledgeBaseId),
        knowledgeBaseName: knowledgeBaseName ?? null,
        title: thread.title,
        status: thread.status,
        lastMessageAt: formatDate(thread.lastMessageAt),
        metadata: parseJsonObject(thread.metadataJson),
        createdAt: formatDate(thread.createdAt),
        updatedAt: formatDate(thread.updatedAt),
    }
}

export function toAgentArtifactResponse(artifact: KnowledgeBaseAgentArtifactRecord) {
    return {
        id: String(artifact.id),
        threadId: String(artifact.threadId),
        runId: artifact.runId == null ? null : String(artifact.runId),
        knowledgeBaseId: artifact.knowledgeBaseId == null ? null : String(artifact.knowledgeBaseId),
        artifactType: artifact.artifactType,
        title: artifact.title,
        payload: parseJsonObject(artifact.payloadJson),
        contentMd: artifact.contentMd,
        createdAt: formatDate(artifact.createdAt),
        updatedAt: formatDate(artifact.updatedAt),
    }
}

export async function assertKnowledgeBaseOwner(db: Db, userId: number, knowledgeBaseId: number) {
    const [record] = await db
        .select()
        .from(knowledgeBases)
        .where(and(eq(knowledgeBases.id, knowledgeBaseId), eq(knowledgeBases.userId, userId)))
        .limit(1)

    if (!record) {
        throw notFound("知识库不存在")
    }
    return record
}

export async function listWikiPages(userId: number, knowledgeBaseId: number) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const pages = await db
        .select()
        .from(knowledgeBaseWikiPages)
        .where(and(eq(knowledgeBaseWikiPages.userId, userId), eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBaseId)))
        .orderBy(asc(knowledgeBaseWikiPages.kind), asc(knowledgeBaseWikiPages.title))

    return pages.map(toWikiPageResponse)
}

export async function loadWikiPageDetail(userId: number, knowledgeBaseId: number, pageKey: string) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const page = await loadWikiPage(db, userId, knowledgeBaseId, pageKey)
    if (!page) {
        throw notFound("Wiki 页面不存在")
    }
    const sourceRefs = await db
        .select({
            id: knowledgeBaseWikiSourceRefs.id,
            articleId: knowledgeBaseWikiSourceRefs.articleId,
            anchor: knowledgeBaseWikiSourceRefs.anchor,
            note: knowledgeBaseWikiSourceRefs.note,
            articleTitle: knowledgeBaseArticles.title,
        })
        .from(knowledgeBaseWikiSourceRefs)
        .innerJoin(knowledgeBaseArticles, eq(knowledgeBaseWikiSourceRefs.articleId, knowledgeBaseArticles.id))
        .where(eq(knowledgeBaseWikiSourceRefs.pageId, page.id))
        .orderBy(asc(knowledgeBaseWikiSourceRefs.id))

    const links = await db
        .select()
        .from(knowledgeBaseWikiLinks)
        .where(eq(knowledgeBaseWikiLinks.fromPageId, page.id))
        .orderBy(asc(knowledgeBaseWikiLinks.toPageKey))

    return {
        ...toWikiPageResponse(page),
        sourceRefs: sourceRefs.map((ref) => ({
            id: String(ref.id),
            articleId: String(ref.articleId),
            articleTitle: ref.articleTitle,
            anchor: ref.anchor,
            note: ref.note,
        })),
        links: links.map((link) => ({
            id: String(link.id),
            toPageKey: link.toPageKey,
            linkType: link.linkType,
        })),
    }
}

export async function loadWikiDashboard(userId: number, knowledgeBaseId: number) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const [kb] = await db
        .select()
        .from(knowledgeBases)
        .where(and(eq(knowledgeBases.id, knowledgeBaseId), eq(knowledgeBases.userId, userId)))
        .limit(1)
    const pages = await listWikiPages(userId, knowledgeBaseId)
    const threads = await listAgentThreads(userId, knowledgeBaseId)
    const patches = await listWikiPatches(userId, knowledgeBaseId, "PENDING")
    const lint = await runWikiLint(userId, knowledgeBaseId)
    const artifacts = await listAgentArtifacts(userId, knowledgeBaseId)
    const [treeNodeRow] = await db
        .select({ value: count() })
        .from(knowledgeBaseWikiTreeNodes)
        .where(and(
            eq(knowledgeBaseWikiTreeNodes.userId, userId),
            eq(knowledgeBaseWikiTreeNodes.knowledgeBaseId, knowledgeBaseId),
        ))

    const embedding = await getKbWikiEmbeddingStatus(userId, knowledgeBaseId)

    return {
        knowledgeBase: kb ? toKnowledgeBaseLite(kb) : null,
        pages,
        threads,
        pendingPatches: patches,
        lint,
        artifacts,
        treeNodeCount: treeNodeRow?.value ?? 0,
        embedding,
    }
}

export async function ingestKnowledgeBaseWiki(input: {
    userId: number
    knowledgeBaseId: number
    articleIds?: number[]
    forceRebuild?: boolean
}) {
    const db = getDb()
    const kb = await assertKnowledgeBaseOwner(db, input.userId, input.knowledgeBaseId)
    const graph = await loadKnowledgeBaseArticles(db, input.userId, input.knowledgeBaseId, input.articleIds)
    if (graph.articles.length === 0) {
        throw badRequest("知识库里还没有可编译的文章")
    }

    const pages: KnowledgeBaseWikiPageRecord[] = []
    const warnings: string[] = []
    for (const article of graph.articles) {
        const sourceHash = stableHash(`${article.title}\n${article.contentMd}`)
        const pageKey = buildArticleSourcePageKey(article.id)
        const existing = await loadWikiPage(db, input.userId, input.knowledgeBaseId, pageKey)
        let page: KnowledgeBaseWikiPageRecord
        if (existing && getFrontmatterSourceHash(existing) === sourceHash && !input.forceRebuild) {
            page = existing
        } else {
            const draft = await generateArticleWikiDraft({
                userId: input.userId,
                knowledgeBaseName: kb.name,
                article,
            }).catch((error: unknown) => {
                warnings.push(error instanceof Error ? error.message : "模型编译失败，已使用本地摘要策略")
                return buildFallbackArticleWikiDraft(article)
            })
            const contentMd = renderArticleWikiPage(article, draft)
            page = await upsertWikiPage(db, {
                userId: input.userId,
                knowledgeBaseId: input.knowledgeBaseId,
                pageKey,
                title: article.title,
                kind: "source",
                contentMd,
                summary: draft.summary,
                frontmatter: {
                    articleId: String(article.id),
                    sourceTitle: article.title,
                    sourceUpdatedAt: formatDate(article.updatedAt),
                    sourceHash,
                    entities: draft.entities,
                    questions: draft.questions,
                },
                sourceRefs: [{ articleId: article.id, anchor: null, note: "源文档" }],
            })
        }
        pages.push(page)

        // PageIndex 式目录树：把源文档拆成层级节点供推理式检索。内部按结构指纹缓存，结构未变会跳过。
        await buildArticleTree({
            db,
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            knowledgeBaseName: kb.name,
            pageId: page.id,
            article,
            forceRebuild: input.forceRebuild,
        }).catch((error: unknown) => {
            warnings.push(error instanceof Error ? `目录树构建失败：${error.message}` : "目录树构建失败")
        })
    }

    const indexPage = await rebuildWikiIndex(db, input.userId, input.knowledgeBaseId, kb.name)
    await logWikiEvent(db, input.userId, input.knowledgeBaseId, "INGEST", indexPage.id, null, {
        articleCount: graph.articles.length,
        pageCount: pages.length + 1,
        warnings,
    })

    // best-effort：编译后自动为新章节节点补写向量（已配置向量模型时才执行），失败不影响编译结果
    await embedKnowledgeBaseTreeNodesBestEffort(input.userId, input.knowledgeBaseId).catch((error: unknown) => {
        warnings.push(error instanceof Error ? `向量生成失败：${error.message}` : "向量生成失败")
    })

    return {
        knowledgeBaseId: String(input.knowledgeBaseId),
        indexPage: toWikiPageResponse(indexPage),
        pages: pages.map(toWikiPageResponse),
        warnings: [...new Set(warnings)].slice(0, 5),
    }
}

export async function listWikiPatches(userId: number, knowledgeBaseId: number, status?: WikiPatchStatus) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const where = status
        ? and(
            eq(knowledgeBaseWikiPatches.userId, userId),
            eq(knowledgeBaseWikiPatches.knowledgeBaseId, knowledgeBaseId),
            eq(knowledgeBaseWikiPatches.status, status),
        )
        : and(eq(knowledgeBaseWikiPatches.userId, userId), eq(knowledgeBaseWikiPatches.knowledgeBaseId, knowledgeBaseId))

    const rows = await db
        .select()
        .from(knowledgeBaseWikiPatches)
        .where(where)
        .orderBy(desc(knowledgeBaseWikiPatches.updatedAt))
        .limit(100)

    return rows.map(toWikiPatchResponse)
}

export async function applyWikiPatch(userId: number, knowledgeBaseId: number, patchId: number) {
    const db = getDb()
    const kb = await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const patch = await loadPatch(db, userId, knowledgeBaseId, patchId)
    if (patch.status !== "PENDING") {
        throw badRequest("只能处理待审批补丁")
    }

    const page = await upsertWikiPage(db, {
        userId,
        knowledgeBaseId,
        pageKey: patch.pageKey,
        title: patch.title,
        kind: patch.operation === "CREATE" ? "answer" : "concept",
        contentMd: patch.proposedContentMd,
        summary: summarizePlainText(patch.proposedContentMd, 180),
        frontmatter: {
            patchId: String(patch.id),
            reason: patch.reason,
        },
        sourceRefs: [],
    })
    const now = new Date()
    const [updated] = await db
        .update(knowledgeBaseWikiPatches)
        .set({ status: "APPLIED", appliedAt: now, updatedAt: now })
        .where(and(eq(knowledgeBaseWikiPatches.id, patch.id), eq(knowledgeBaseWikiPatches.userId, userId)))
        .returning()
    await rebuildWikiIndex(db, userId, knowledgeBaseId, kb.name)
    await logWikiEvent(db, userId, knowledgeBaseId, "PATCH_APPLIED", page.id, patch.threadId, {
        patchId: String(patch.id),
        pageKey: patch.pageKey,
    })

    return {
        patch: toWikiPatchResponse(updated),
        page: toWikiPageResponse(page),
    }
}

export async function rejectWikiPatch(userId: number, knowledgeBaseId: number, patchId: number) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const patch = await loadPatch(db, userId, knowledgeBaseId, patchId)
    if (patch.status !== "PENDING") {
        throw badRequest("只能处理待审批补丁")
    }
    const [updated] = await db
        .update(knowledgeBaseWikiPatches)
        .set({ status: "REJECTED", updatedAt: new Date() })
        .where(and(eq(knowledgeBaseWikiPatches.id, patch.id), eq(knowledgeBaseWikiPatches.userId, userId)))
        .returning()
    await logWikiEvent(db, userId, knowledgeBaseId, "PATCH_REJECTED", null, patch.threadId, {
        patchId: String(patch.id),
        pageKey: patch.pageKey,
    })
    return toWikiPatchResponse(updated)
}

export async function listAgentThreads(userId: number, knowledgeBaseId: number) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const rows = await db
        .select()
        .from(knowledgeBaseAgentThreads)
        .where(and(eq(knowledgeBaseAgentThreads.userId, userId), eq(knowledgeBaseAgentThreads.knowledgeBaseId, knowledgeBaseId)))
        .orderBy(desc(knowledgeBaseAgentThreads.updatedAt), desc(knowledgeBaseAgentThreads.id))
        .limit(50)

    return rows.map((row) => toAgentThreadResponse(row))
}

export async function createAgentThread(input: {
    userId: number
    knowledgeBaseId: number | null
    title?: string | null
    metadata?: unknown
}) {
    const db = getDb()
    if (input.knowledgeBaseId != null) {
        await assertKnowledgeBaseOwner(db, input.userId, input.knowledgeBaseId)
    }
    const now = new Date()
    const [thread] = await db
        .insert(knowledgeBaseAgentThreads)
        .values({
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            title: input.title?.trim() || "文档问答",
            lastMessageAt: now,
            metadataJson: input.metadata === undefined ? null : JSON.stringify(input.metadata),
        })
        .returning()

    return thread
}

export async function ensureAgentThread(input: {
    userId: number
    knowledgeBaseId: number | null
    threadId?: number | null
    title?: string | null
}) {
    if (input.threadId != null) {
        return await loadAgentThreadOrThrow(input.userId, input.threadId, input.knowledgeBaseId)
    }
    return await createAgentThread(input)
}

export async function loadAgentThreadOrThrow(userId: number, threadId: number, expectedKnowledgeBaseId?: number | null) {
    const db = getDb()
    const [thread] = await db
        .select()
        .from(knowledgeBaseAgentThreads)
        .where(and(
            eq(knowledgeBaseAgentThreads.id, threadId),
            eq(knowledgeBaseAgentThreads.userId, userId),
        ))
        .limit(1)
    if (!thread) {
        throw notFound("对话线程不存在")
    }
    if (expectedKnowledgeBaseId !== undefined && expectedKnowledgeBaseId !== null
        && thread.knowledgeBaseId !== expectedKnowledgeBaseId) {
        throw notFound("对话线程不存在")
    }
    return thread
}

export async function loadAgentThreadDetail(userId: number, threadId: number, expectedKnowledgeBaseId?: number | null) {
    const thread = await loadAgentThreadOrThrow(userId, threadId, expectedKnowledgeBaseId)
    const db = getDb()
    const messages = await db
        .select()
        .from(knowledgeBaseAgentMessages)
        .where(eq(knowledgeBaseAgentMessages.threadId, thread.id))
        .orderBy(asc(knowledgeBaseAgentMessages.createdAt), asc(knowledgeBaseAgentMessages.id))
        .limit(200)

    const kbName = thread.knowledgeBaseId == null
        ? null
        : await loadKnowledgeBaseName(db, userId, thread.knowledgeBaseId)

    return {
        thread: toAgentThreadResponse(thread, kbName),
        messages: messages.map((message) => ({
            id: String(message.id),
            role: message.role,
            contentText: message.contentText,
            content: parseJsonObject(message.contentJson),
            metadata: parseJsonObject(message.metadataJson),
            createdAt: formatDate(message.createdAt),
        })),
    }
}

async function loadKnowledgeBaseName(db: Db, userId: number, knowledgeBaseId: number) {
    const [kb] = await db
        .select({ name: knowledgeBases.name })
        .from(knowledgeBases)
        .where(and(eq(knowledgeBases.id, knowledgeBaseId), eq(knowledgeBases.userId, userId)))
        .limit(1)
    return kb?.name ?? null
}

export async function deleteAgentThread(userId: number, threadId: number) {
    const db = getDb()
    const thread = await loadAgentThreadOrThrow(userId, threadId)

    await db.delete(knowledgeBaseAgentMessages).where(eq(knowledgeBaseAgentMessages.threadId, thread.id))

    const runs = await db
        .select({ id: knowledgeBaseAgentRuns.id })
        .from(knowledgeBaseAgentRuns)
        .where(eq(knowledgeBaseAgentRuns.threadId, thread.id))
    if (runs.length > 0) {
        const runIds = runs.map((row) => row.id)
        await db.delete(knowledgeBaseAgentSteps).where(inArray(knowledgeBaseAgentSteps.runId, runIds))
        await db.delete(knowledgeBaseAgentRuns).where(inArray(knowledgeBaseAgentRuns.id, runIds))
    }

    await db.delete(knowledgeBaseAgentArtifacts).where(eq(knowledgeBaseAgentArtifacts.threadId, thread.id))

    if (thread.knowledgeBaseId != null) {
        await db
            .update(knowledgeBaseWikiPatches)
            .set({ threadId: null })
            .where(and(
                eq(knowledgeBaseWikiPatches.userId, userId),
                eq(knowledgeBaseWikiPatches.threadId, thread.id),
            ))
        await db
            .update(knowledgeBaseWikiEventLogs)
            .set({ threadId: null })
            .where(and(
                eq(knowledgeBaseWikiEventLogs.userId, userId),
                eq(knowledgeBaseWikiEventLogs.threadId, thread.id),
            ))
    }

    await db
        .delete(knowledgeBaseAgentThreads)
        .where(and(eq(knowledgeBaseAgentThreads.id, thread.id), eq(knowledgeBaseAgentThreads.userId, userId)))

    return { id: String(thread.id) }
}

export type ListAgentThreadsScope =
    | { type: "all" }
    | { type: "cross" }
    | { type: "kb"; knowledgeBaseId: number }

export function parseAgentThreadScope(raw: string | undefined | null): ListAgentThreadsScope | null {
    if (raw === undefined || raw === null || raw === "" || raw === "all") return { type: "all" }
    if (raw === "cross") return { type: "cross" }
    const id = Number(String(raw).trim())
    if (!Number.isInteger(id) || id <= 0) return null
    return { type: "kb", knowledgeBaseId: id }
}

export async function listAllAgentThreads(
    userId: number,
    options: {
        cursor?: number
        limit?: number
        query?: string
        scope?: ListAgentThreadsScope
    } = {},
) {
    const db = getDb()
    const limit = Math.min(Math.max(options.limit ?? 30, 1), 100)
    const offset = options.cursor ?? 0
    const scope = options.scope ?? { type: "all" }

    const filters = [eq(knowledgeBaseAgentThreads.userId, userId)]
    if (scope.type === "cross") {
        filters.push(isNull(knowledgeBaseAgentThreads.knowledgeBaseId))
    } else if (scope.type === "kb") {
        filters.push(eq(knowledgeBaseAgentThreads.knowledgeBaseId, scope.knowledgeBaseId))
    }
    const keyword = options.query?.trim()
    if (keyword) {
        const pattern = `%${keyword.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`
        const titleMatch = like(knowledgeBaseAgentThreads.title, pattern)
        const kbMatch = like(knowledgeBases.name, pattern)
        const combined = or(titleMatch, kbMatch)
        if (combined) filters.push(combined)
    }

    const rows = await db
        .select({
            thread: knowledgeBaseAgentThreads,
            kbName: knowledgeBases.name,
        })
        .from(knowledgeBaseAgentThreads)
        .leftJoin(knowledgeBases, eq(knowledgeBaseAgentThreads.knowledgeBaseId, knowledgeBases.id))
        .where(and(...filters))
        .orderBy(desc(knowledgeBaseAgentThreads.updatedAt), desc(knowledgeBaseAgentThreads.id))
        .limit(limit + 1)
        .offset(offset)

    const hasMore = rows.length > limit
    const sliced = hasMore ? rows.slice(0, limit) : rows
    return {
        threads: sliced.map((row) => toAgentThreadResponse(row.thread, row.kbName)),
        nextCursor: hasMore ? offset + limit : null,
    }
}

export async function deleteAgentThreads(userId: number, threadIds: number[]) {
    if (threadIds.length === 0) {
        return { deleted: [] as string[], failed: [] as Array<{ id: string; reason: string }> }
    }
    const db = getDb()
    const uniqueIds = Array.from(new Set(threadIds))
    const ownedRows = await db
        .select({ id: knowledgeBaseAgentThreads.id })
        .from(knowledgeBaseAgentThreads)
        .where(and(
            eq(knowledgeBaseAgentThreads.userId, userId),
            inArray(knowledgeBaseAgentThreads.id, uniqueIds),
        ))
    const ownedIds = ownedRows.map((row) => row.id)
    const ownedSet = new Set(ownedIds.map((id) => String(id)))
    const failed = uniqueIds
        .filter((id) => !ownedSet.has(String(id)))
        .map((id) => ({ id: String(id), reason: "对话不存在或无权限" }))

    if (ownedIds.length === 0) {
        return { deleted: [] as string[], failed }
    }

    await db.transaction(async (tx) => {
        const runs = await tx
            .select({ id: knowledgeBaseAgentRuns.id })
            .from(knowledgeBaseAgentRuns)
            .where(inArray(knowledgeBaseAgentRuns.threadId, ownedIds))
        const runIds = runs.map((row) => row.id)

        if (runIds.length > 0) {
            await tx
                .delete(knowledgeBaseAgentSteps)
                .where(inArray(knowledgeBaseAgentSteps.runId, runIds))
        }

        await tx
            .delete(knowledgeBaseAgentMessages)
            .where(inArray(knowledgeBaseAgentMessages.threadId, ownedIds))

        if (runIds.length > 0) {
            await tx
                .delete(knowledgeBaseAgentRuns)
                .where(inArray(knowledgeBaseAgentRuns.id, runIds))
        }

        await tx
            .delete(knowledgeBaseAgentArtifacts)
            .where(inArray(knowledgeBaseAgentArtifacts.threadId, ownedIds))

        await tx
            .update(knowledgeBaseWikiPatches)
            .set({ threadId: null })
            .where(and(
                eq(knowledgeBaseWikiPatches.userId, userId),
                inArray(knowledgeBaseWikiPatches.threadId, ownedIds),
            ))

        await tx
            .update(knowledgeBaseWikiEventLogs)
            .set({ threadId: null })
            .where(and(
                eq(knowledgeBaseWikiEventLogs.userId, userId),
                inArray(knowledgeBaseWikiEventLogs.threadId, ownedIds),
            ))

        await tx
            .delete(knowledgeBaseAgentThreads)
            .where(and(
                eq(knowledgeBaseAgentThreads.userId, userId),
                inArray(knowledgeBaseAgentThreads.id, ownedIds),
            ))
    })

    return {
        deleted: ownedIds.map((id) => String(id)),
        failed,
    }
}

export async function listUserKnowledgeBases(userId: number) {
    const db = getDb()
    const rows = await db
        .select({ id: knowledgeBases.id, name: knowledgeBases.name, description: knowledgeBases.description })
        .from(knowledgeBases)
        .where(eq(knowledgeBases.userId, userId))
        .orderBy(asc(knowledgeBases.name))
    return rows.map((row) => ({
        id: String(row.id),
        name: row.name,
        description: row.description,
    }))
}

export async function searchWikiPagesAcrossKbs(input: {
    userId: number
    query: string
    limit?: number
}) {
    const db = getDb()
    const limit = input.limit ?? 10
    const query = input.query.trim()
    const [pageRows, articleRows] = await Promise.all([
        db
            .select({
                page: knowledgeBaseWikiPages,
                kbName: knowledgeBases.name,
            })
            .from(knowledgeBaseWikiPages)
            .innerJoin(knowledgeBases, eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBases.id))
            .where(eq(knowledgeBaseWikiPages.userId, input.userId))
            .orderBy(desc(knowledgeBaseWikiPages.updatedAt))
            .limit(500),
        db
            .select({
                article: knowledgeBaseArticles,
                kbName: knowledgeBases.name,
            })
            .from(knowledgeBaseArticles)
            .innerJoin(knowledgeBases, eq(knowledgeBaseArticles.knowledgeBaseId, knowledgeBases.id))
            .where(eq(knowledgeBaseArticles.userId, input.userId))
            .orderBy(desc(knowledgeBaseArticles.updatedAt))
            .limit(500),
    ])

    type CrossHit = {
        knowledgeBaseId: string
        knowledgeBaseName: string
        pageKey: string
        articleId: string | null
        href: string | null
        title: string
        kind: string
        summary: string
        updatedAt: string
        score: number
        sortTime: number
    }

    const byKey = new Map<string, CrossHit>()

    for (const { page, kbName } of pageRows) {
        const score = scoreWikiPage(page, query)
        if (score <= 0 && query) continue
        const articleId = extractArticleIdFromPageKey(page.pageKey)
        const key = articleId
            ? `article:${page.knowledgeBaseId}:${articleId}`
            : `page:${page.knowledgeBaseId}:${page.pageKey}`
        const hit: CrossHit = {
            knowledgeBaseId: String(page.knowledgeBaseId),
            knowledgeBaseName: kbName,
            pageKey: page.pageKey,
            articleId,
            href: sourceArticleHref(page.knowledgeBaseId, articleId),
            title: page.title,
            kind: page.kind,
            summary: page.summary || summarizePlainText(page.contentMd, 180),
            updatedAt: formatDate(page.updatedAt) ?? "",
            score: score || 1,
            sortTime: page.updatedAt.getTime(),
        }
        const existing = byKey.get(key)
        if (!existing || hit.score > existing.score) byKey.set(key, hit)
    }

    // Wiki 未编译或标题只在文章表时，用文章标题/摘要补召回
    for (const { article, kbName } of articleRows) {
        const score = scoreSearchFields({
            title: article.title,
            summary: article.aiSummary ?? article.publicExcerpt,
            content: article.contentMd,
        }, query)
        if (score <= 0 && query) continue
        const key = `article:${article.knowledgeBaseId}:${article.id}`
        const hit: CrossHit = {
            knowledgeBaseId: String(article.knowledgeBaseId),
            knowledgeBaseName: kbName,
            pageKey: `source-${article.id}`,
            articleId: String(article.id),
            href: knowledgeBaseArticlePath(String(article.knowledgeBaseId), String(article.id)),
            title: article.title,
            kind: "source",
            summary: article.aiSummary?.trim()
                || article.publicExcerpt?.trim()
                || summarizePlainText(article.contentMd, 180),
            updatedAt: formatDate(article.updatedAt) ?? "",
            score: score || 1,
            sortTime: article.updatedAt.getTime(),
        }
        const existing = byKey.get(key)
        if (!existing || hit.score > existing.score) byKey.set(key, hit)
    }

    return Array.from(byKey.values())
        .sort((left, right) => right.score - left.score || right.sortTime - left.sortTime)
        .slice(0, limit)
        .map(({ score: _score, sortTime: _sortTime, ...hit }) => hit)
}

function extractArticleIdFromPageKey(pageKey: string): string | null {
    const match = pageKey.match(/^source-(\d+)$/)
    return match ? match[1] : null
}

function sourceArticleHref(knowledgeBaseId: number | string, articleId: string | null) {
    return articleId ? knowledgeBaseArticlePath(String(knowledgeBaseId), articleId) : null
}

export async function readWikiPageAnyKb(userId: number, knowledgeBaseId: number, pageKey: string) {
    return await readWikiPageForAgent(userId, knowledgeBaseId, pageKey)
}

export async function createAgentRun(input: {
    userId: number
    knowledgeBaseId: number | null
    threadId: number
    modelName?: string | null
}) {
    const [run] = await getDb()
        .insert(knowledgeBaseAgentRuns)
        .values({
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            threadId: input.threadId,
            modelName: input.modelName ?? null,
        })
        .returning()
    return run
}

export async function finishAgentRun(input: {
    runId: number
    userId: number
    status: "COMPLETED" | "FAILED" | "CANCELLED"
    errorMessage?: string | null
}) {
    await getDb()
        .update(knowledgeBaseAgentRuns)
        .set({
            status: input.status,
            errorMessage: input.errorMessage ?? null,
            finishedAt: new Date(),
        })
        .where(and(eq(knowledgeBaseAgentRuns.id, input.runId), eq(knowledgeBaseAgentRuns.userId, input.userId)))
}

export async function recordAgentStep(input: {
    runId: number
    userId: number
    knowledgeBaseId: number | null
    stepType: string
    title: string
    status: "RUNNING" | "COMPLETED" | "FAILED"
    payload?: unknown
}) {
    const now = new Date()
    await getDb()
        .insert(knowledgeBaseAgentSteps)
        .values({
            runId: input.runId,
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            stepType: input.stepType,
            title: input.title,
            status: input.status,
            payloadJson: input.payload === undefined ? null : JSON.stringify(input.payload),
            startedAt: now,
            finishedAt: input.status === "RUNNING" ? null : now,
        })
}

export async function persistAgentMessage(input: {
    userId: number
    knowledgeBaseId: number | null
    threadId: number
    role: "user" | "assistant" | "system" | "tool"
    contentText: string
    content?: unknown
    metadata?: unknown
}) {
    const now = new Date()
    const [message] = await getDb()
        .insert(knowledgeBaseAgentMessages)
        .values({
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            threadId: input.threadId,
            role: input.role,
            contentText: input.contentText,
            contentJson: input.content === undefined ? null : JSON.stringify(input.content),
            metadataJson: input.metadata === undefined ? null : JSON.stringify(input.metadata),
            createdAt: now,
        })
        .returning()

    await getDb()
        .update(knowledgeBaseAgentThreads)
        .set({
            lastMessageAt: now,
            updatedAt: now,
            ...(input.role === "user" && input.contentText.trim()
                ? { title: summarizePlainText(input.contentText, 40) }
                : {}),
        })
        .where(and(eq(knowledgeBaseAgentThreads.id, input.threadId), eq(knowledgeBaseAgentThreads.userId, input.userId)))

    return message
}

export async function listAgentArtifacts(userId: number, knowledgeBaseId: number, threadId?: number | null) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const where = threadId == null
        ? and(eq(knowledgeBaseAgentArtifacts.userId, userId), eq(knowledgeBaseAgentArtifacts.knowledgeBaseId, knowledgeBaseId))
        : and(
            eq(knowledgeBaseAgentArtifacts.userId, userId),
            eq(knowledgeBaseAgentArtifacts.knowledgeBaseId, knowledgeBaseId),
            eq(knowledgeBaseAgentArtifacts.threadId, threadId),
        )

    const rows = await db
        .select()
        .from(knowledgeBaseAgentArtifacts)
        .where(where)
        .orderBy(desc(knowledgeBaseAgentArtifacts.updatedAt))
        .limit(100)

    return rows.map(toAgentArtifactResponse)
}

export async function createAgentArtifact(input: {
    userId: number
    knowledgeBaseId: number | null
    threadId: number
    runId?: number | null
    artifactType: string
    title: string
    payload?: unknown
    contentMd?: string | null
}) {
    const [artifact] = await getDb()
        .insert(knowledgeBaseAgentArtifacts)
        .values({
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            threadId: input.threadId,
            runId: input.runId ?? null,
            artifactType: input.artifactType,
            title: input.title,
            payloadJson: input.payload === undefined ? null : JSON.stringify(input.payload),
            contentMd: input.contentMd ?? null,
        })
        .returning()
    return toAgentArtifactResponse(artifact)
}

export async function runWikiLint(userId: number, knowledgeBaseId: number) {
    const db = getDb()
    await assertKnowledgeBaseOwner(db, userId, knowledgeBaseId)
    const pages = await db
        .select()
        .from(knowledgeBaseWikiPages)
        .where(and(eq(knowledgeBaseWikiPages.userId, userId), eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBaseId)))
        .orderBy(asc(knowledgeBaseWikiPages.kind), asc(knowledgeBaseWikiPages.title))
    const links = pages.length > 0
        ? await db
            .select()
            .from(knowledgeBaseWikiLinks)
            .where(and(eq(knowledgeBaseWikiLinks.userId, userId), eq(knowledgeBaseWikiLinks.knowledgeBaseId, knowledgeBaseId)))
        : []
    const refs = pages.length > 0
        ? await db
            .select()
            .from(knowledgeBaseWikiSourceRefs)
            .where(inArray(knowledgeBaseWikiSourceRefs.pageId, pages.map((page) => page.id)))
        : []
    const pageKeys = new Set(pages.map((page) => page.pageKey))
    const linkedFrom = new Map<string, number>()
    for (const link of links) {
        linkedFrom.set(link.toPageKey, (linkedFrom.get(link.toPageKey) ?? 0) + 1)
    }

    const issues = [
        ...pages
            .filter((page) => page.kind !== "index" && !refs.some((ref) => ref.pageId === page.id))
            .map((page) => ({
                severity: "warning" as const,
                code: "missing_source",
                pageKey: page.pageKey,
                title: page.title,
                message: "页面缺少来源引用",
            })),
        ...links
            .filter((link) => !pageKeys.has(link.toPageKey))
            .map((link) => ({
                severity: "error" as const,
                code: "broken_link",
                pageKey: link.toPageKey,
                title: link.toPageKey,
                message: "链接指向不存在的 Wiki 页面",
            })),
        ...pages
            .filter((page) => page.kind !== "index" && !linkedFrom.has(page.pageKey))
            .slice(0, 20)
            .map((page) => ({
                severity: "info" as const,
                code: "orphan_page",
                pageKey: page.pageKey,
                title: page.title,
                message: "页面暂时没有被其他页面引用",
            })),
    ]

    const score = Math.max(0, 100 - issues.filter((issue) => issue.severity === "error").length * 25 - issues.filter((issue) => issue.severity === "warning").length * 8)
    return {
        score,
        pageCount: pages.length,
        linkCount: links.length,
        sourceRefCount: refs.length,
        issueCount: issues.length,
        issues,
        checkedAt: new Date().toISOString(),
    }
}

export async function searchWikiPagesForAgent(input: {
    userId: number
    knowledgeBaseId: number
    query: string
    limit?: number
}) {
    const pages = await listWikiPagesRaw(input.userId, input.knowledgeBaseId)
    const query = input.query.trim()
    const ranked = pages
        .map((page) => ({
            page,
            score: scoreWikiPage(page, query),
        }))
        .filter((item) => item.score > 0 || !query)
        .sort((left, right) => right.score - left.score || right.page.updatedAt.getTime() - left.page.updatedAt.getTime())
        .slice(0, input.limit ?? 8)

    return ranked.map((item) => {
        const articleId = extractArticleIdFromPageKey(item.page.pageKey)
        return {
            pageKey: item.page.pageKey,
            articleId,
            href: sourceArticleHref(input.knowledgeBaseId, articleId),
            title: item.page.title,
            kind: item.page.kind,
            summary: item.page.summary || summarizePlainText(item.page.contentMd, 180),
            updatedAt: formatDate(item.page.updatedAt),
        }
    })
}

export async function readWikiPageForAgent(userId: number, knowledgeBaseId: number, pageKey: string) {
    const detail = await loadWikiPageDetail(userId, knowledgeBaseId, pageKey)
    const sourceArticleIds = detail.sourceRefs
        .map((ref) => Number(ref.articleId))
        .filter((id) => Number.isInteger(id) && id > 0)
    const media = mergeAgentImageReferences([
        ...extractAgentImageReferences(detail.contentMd),
        ...await loadArticleImageReferences(userId, knowledgeBaseId, sourceArticleIds),
    ])
    return {
        knowledgeBaseId: String(knowledgeBaseId),
        pageKey: detail.pageKey,
        articleId: extractArticleIdFromPageKey(detail.pageKey),
        href: sourceArticleHref(knowledgeBaseId, extractArticleIdFromPageKey(detail.pageKey)),
        title: detail.title,
        kind: detail.kind,
        contentMd: detail.contentMd,
        media,
        sourceRefs: detail.sourceRefs,
        links: detail.links,
    }
}

export async function readSourceArticleForAgent(userId: number, knowledgeBaseId: number, articleId: number) {
    const [article] = await getDb()
        .select()
        .from(knowledgeBaseArticles)
        .where(and(
            eq(knowledgeBaseArticles.id, articleId),
            eq(knowledgeBaseArticles.userId, userId),
            eq(knowledgeBaseArticles.knowledgeBaseId, knowledgeBaseId),
        ))
        .limit(1)
    if (!article) {
        throw notFound("源文档不存在")
    }
    return {
        knowledgeBaseId: String(knowledgeBaseId),
        articleId: String(article.id),
        href: knowledgeBaseArticlePath(String(knowledgeBaseId), String(article.id)),
        title: article.title,
        contentMd: article.contentMd,
        media: extractAgentImageReferences(article.contentMd, {
            sourceArticleId: String(article.id),
            sourceArticleTitle: article.title,
        }),
        updatedAt: formatDate(article.updatedAt),
    }
}

// 各类媒体的扩展名归类——决定渲染层用 <img>/<video>/<audio>/<file> 中的哪一个。
const MEDIA_EXTENSION_PATTERNS: Record<AgentMediaKind, RegExp> = {
    image: /\.(?:png|jpe?g|gif|webp|avif|svg|bmp)(?:[?#].*)?$/i,
    video: /\.(?:mp4|webm|ogv|mov|m4v|mkv)(?:[?#].*)?$/i,
    audio: /\.(?:mp3|wav|m4a|aac|flac|ogg|oga)(?:[?#].*)?$/i,
    file: /\.(?:pdf|docx?|pptx?|xlsx?|csv|txt|md|zip|rar|7z|json)(?:[?#].*)?$/i,
}
// image/video/audio 走扩展名识别；其余存储对象一律兜底为可下载附件（file）。
const MEDIA_EXTENSION_ORDER: AgentMediaKind[] = ["image", "video", "audio", "file"]

const MARKDOWN_IMAGE_PATTERN = /!\[([^\]\n]*)]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
// 普通 Markdown 链接（排除图片语法 ![]()），用于附件/视频下载链接。
const MARKDOWN_LINK_PATTERN = /(?<!!)\[([^\]\n]*)]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
// 编辑器上传的图片/视频/附件以 <img>/<video>/<audio>/<file ... src="s4key:..."> 形式入库。
const HTML_MEDIA_PATTERN = /<(?:img|video|audio|source|file)\b[^>]*\bsrc=(["'])(.*?)\1[^>]*>/gi
const HTML_ANCHOR_PATTERN = /<a\b[^>]*\bhref=(["'])(.*?)\1[^>]*>/gi
// 裸存储路径：uploads/<id>/<对象> + 任意扩展名（含 .icls 等自定义附件）。
const RAW_STORAGE_MEDIA_PATTERN = /(?:s4key:)?\/?uploads\/\d+\/[^\s"'<>()[\]{}]+?\.[A-Za-z0-9]{1,12}(?:[?#][^\s"'<>()[\]{}]*)?/g

/** 仅按扩展名识别 image/video/audio/file；无法识别返回 null。 */
function classifyMediaExtension(path: string): AgentMediaKind | null {
    for (const kind of MEDIA_EXTENSION_ORDER) {
        if (MEDIA_EXTENSION_PATTERNS[kind].test(path)) return kind
    }
    return null
}

/** 按媒体类型分配稳定 id（image-1 / video-1 / file-1 …），同类内自增。 */
function makeMediaIdAssigner() {
    const counters: Record<AgentMediaKind, number> = { image: 0, video: 0, audio: 0, file: 0 }
    return (kind: AgentMediaKind) => `${kind}-${(counters[kind] += 1)}`
}

/** 从 HTML 标签中按优先级取第一个非空属性值（如 <file name> 的真实文件名）。 */
function extractHtmlAttr(rawTag: string | undefined, attrNames: string[]): string | undefined {
    if (!rawTag) return undefined
    for (const attr of attrNames) {
        const match = rawTag.match(new RegExp(`\\b${attr}=(["'])(.*?)\\1`, "i"))
        const value = match?.[2]?.trim()
        if (value) return value
    }
    return undefined
}

export function extractAgentImageReferences(
    markdown: string | null | undefined,
    source?: { sourceArticleId?: string; sourceArticleTitle?: string },
): AgentImageReference[] {
    if (!markdown?.trim()) return []
    const refs: AgentImageReference[] = []
    const seen = new Set<string>()
    const assignId = makeMediaIdAssigner()

    function add(rawSrc: string | undefined, opts?: { label?: string; filename?: string }) {
        const normalized = normalizeAgentMediaSource(rawSrc)
        if (!normalized) return
        const key = normalized.objectKey ?? normalized.src
        if (seen.has(key)) return
        seen.add(key)
        // 显式文件名（如 <file name="真实名.icls">）优先于从 UUID 对象键推断的名字。
        const filename = opts?.filename?.trim() || normalized.filename
        refs.push({
            id: assignId(normalized.kind),
            kind: normalized.kind,
            alt: normalizeMediaAlt(opts?.label, filename, normalized.kind),
            src: normalized.src,
            objectKey: normalized.objectKey,
            filename,
            ...source,
        })
    }

    for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
        add(match[2], { label: match[1] })
    }

    for (const match of markdown.matchAll(MARKDOWN_LINK_PATTERN)) {
        add(match[2], { label: match[1] })
    }

    for (const match of markdown.matchAll(HTML_MEDIA_PATTERN)) {
        const name = extractHtmlAttr(match[0], ["name", "download", "title"])
        const alt = extractHtmlAttr(match[0], ["alt"])
        add(match[2], { label: name ?? alt, filename: name })
    }

    for (const match of markdown.matchAll(HTML_ANCHOR_PATTERN)) {
        const name = extractHtmlAttr(match[0], ["download", "title"])
        add(match[2], { label: name, filename: name })
    }

    for (const match of markdown.matchAll(RAW_STORAGE_MEDIA_PATTERN)) {
        add(match[0])
    }

    return refs.slice(0, 20)
}

async function loadArticleImageReferences(userId: number, knowledgeBaseId: number, articleIds: number[]) {
    const uniqueArticleIds = Array.from(new Set(articleIds)).slice(0, 10)
    if (uniqueArticleIds.length === 0) return []
    const rows = await getDb()
        .select({
            id: knowledgeBaseArticles.id,
            title: knowledgeBaseArticles.title,
            contentMd: knowledgeBaseArticles.contentMd,
        })
        .from(knowledgeBaseArticles)
        .where(and(
            eq(knowledgeBaseArticles.userId, userId),
            eq(knowledgeBaseArticles.knowledgeBaseId, knowledgeBaseId),
            inArray(knowledgeBaseArticles.id, uniqueArticleIds),
        ))

    return rows.flatMap((article) => extractAgentImageReferences(article.contentMd, {
        sourceArticleId: String(article.id),
        sourceArticleTitle: article.title,
    }))
}

function mergeAgentImageReferences(refs: AgentImageReference[]) {
    const merged: AgentImageReference[] = []
    const seen = new Set<string>()
    const assignId = makeMediaIdAssigner()
    for (const ref of refs) {
        const key = ref.objectKey ?? ref.src
        if (seen.has(key)) continue
        seen.add(key)
        merged.push({ ...ref, id: assignId(ref.kind) })
    }
    return merged.slice(0, 20)
}

function normalizeAgentMediaSource(rawSrc: string | undefined) {
    const src = rawSrc?.trim().replace(/^<|>$/g, "")
    if (!src) return null

    const storageUrl = normalizeS4ObjectUrl(src)
    if (storageUrl) {
        const objectKey = normalizeS4ObjectKey(storageUrl)
        if (!objectKey) return null
        // 存储对象一律可下载：识别不出 image/video/audio 的（如 .icls）兜底为 file。
        const kind = classifyMediaExtension(objectKey) ?? "file"
        return {
            src: storageUrl,
            objectKey,
            filename: extractMediaFilename(objectKey),
            kind,
        }
    }

    const kind = classifyExternalMediaKind(src)
    if (!kind) return null
    return {
        src,
        objectKey: null,
        filename: extractMediaFilename(src),
        kind,
    }
}

function classifyExternalMediaKind(src: string): AgentMediaKind | null {
    if (/^data:image\//i.test(src)) return "image"
    if (!/^https?:\/\//i.test(src)) return null
    try {
        const url = new URL(src)
        return classifyMediaExtension(url.pathname)
    } catch {
        return null
    }
}

function extractMediaFilename(src: string) {
    const clean = src.split(/[?#]/)[0] ?? src
    const part = clean.split("/").filter(Boolean).at(-1)
    if (!part) return "附件"
    try {
        return decodeURIComponent(part)
    } catch {
        return part
    }
}

const MEDIA_KIND_FALLBACK_LABEL: Record<AgentMediaKind, string> = {
    image: "图片",
    video: "视频",
    audio: "音频",
    file: "附件",
}

function normalizeMediaAlt(rawAlt: string | undefined, filename: string, kind: AgentMediaKind) {
    const alt = rawAlt?.trim()
    return alt || filename || MEDIA_KIND_FALLBACK_LABEL[kind]
}

export async function proposeWikiPatchFromAgent(input: {
    userId: number
    knowledgeBaseId: number
    threadId?: number | null
    runId?: number | null
    pageKey: string
    title: string
    proposedContentMd: string
    reason?: string | null
}) {
    const db = getDb()
    const normalizedPageKey = normalizePageKey(input.pageKey)
    const existing = await loadWikiPage(db, input.userId, input.knowledgeBaseId, normalizedPageKey)
    const operation = existing ? "UPDATE" : "CREATE"
    const diffText = buildSimpleUnifiedDiff(existing?.contentMd ?? "", input.proposedContentMd, existing?.title ?? input.title)
    const [patch] = await db
        .insert(knowledgeBaseWikiPatches)
        .values({
            userId: input.userId,
            knowledgeBaseId: input.knowledgeBaseId,
            threadId: input.threadId ?? null,
            runId: input.runId ?? null,
            pageKey: normalizedPageKey,
            title: input.title,
            operation,
            beforeContentMd: existing?.contentMd ?? null,
            proposedContentMd: input.proposedContentMd,
            diffText,
            reason: input.reason ?? null,
        })
        .returning()
    await logWikiEvent(db, input.userId, input.knowledgeBaseId, "PATCH_PROPOSED", existing?.id ?? null, input.threadId ?? null, {
        patchId: String(patch.id),
        pageKey: normalizedPageKey,
        operation,
    })
    return toWikiPatchResponse(patch)
}

async function loadKnowledgeBaseArticles(db: Db, userId: number, knowledgeBaseId: number, articleIds?: number[]) {
    const nodes = await db
        .select()
        .from(knowledgeBaseNodes)
        .where(and(eq(knowledgeBaseNodes.userId, userId), eq(knowledgeBaseNodes.knowledgeBaseId, knowledgeBaseId)))
    const articleFilter = articleIds?.length
        ? and(
            eq(knowledgeBaseArticles.userId, userId),
            eq(knowledgeBaseArticles.knowledgeBaseId, knowledgeBaseId),
            inArray(knowledgeBaseArticles.id, articleIds),
        )
        : and(eq(knowledgeBaseArticles.userId, userId), eq(knowledgeBaseArticles.knowledgeBaseId, knowledgeBaseId))
    const articles = await db
        .select()
        .from(knowledgeBaseArticles)
        .where(articleFilter)
        .orderBy(asc(knowledgeBaseArticles.updatedAt), asc(knowledgeBaseArticles.id))
    return { nodes, articles }
}

async function generateArticleWikiDraft(input: {
    userId: number
    knowledgeBaseName: string
    article: KnowledgeBaseArticleRecord
}): Promise<ArticleWikiDraft> {
    const content = input.article.contentMd.length > 12000
        ? `${input.article.contentMd.slice(0, 12000)}\n\n[内容已截断]`
        : input.article.contentMd
    const result = await callChatCompletion({
        userId: input.userId,
        systemPrompt: [
            "你是一个文档 Wiki 编译 Agent。",
            "请把源文档编译成可长期维护的 Wiki 中间层元数据。",
            "只输出 JSON，不要输出 Markdown 围栏。",
            "JSON 字段：summary:string, keyPoints:string[], entities:string[], questions:string[]。",
        ].join("\n"),
        message: [
            `知识库：${input.knowledgeBaseName}`,
            `文档标题：${input.article.title}`,
            "文档内容：",
            content,
        ].join("\n\n"),
    })
    const parsed = safeParseJsonObject(result.answer)
    const normalized = llmArticleWikiSchema.parse(parsed)
    return {
        summary: normalized.summary?.trim() || summarizePlainText(input.article.contentMd, 240),
        keyPoints: normalizeStringList(normalized.keyPoints).slice(0, 12),
        entities: normalizeStringList(normalized.entities).slice(0, 20),
        questions: normalizeStringList(normalized.questions).slice(0, 8),
    }
}

function buildFallbackArticleWikiDraft(article: KnowledgeBaseArticleRecord): ArticleWikiDraft {
    const headings = extractMarkdownHeadings(article.contentMd)
    return {
        summary: summarizePlainText(article.contentMd, 240),
        keyPoints: headings.length > 0 ? headings.slice(0, 12) : splitSentences(article.contentMd).slice(0, 8),
        entities: [],
        questions: [
            `${article.title} 的核心结论是什么？`,
            `${article.title} 中有哪些关键概念？`,
        ],
    }
}

function renderArticleWikiPage(article: KnowledgeBaseArticleRecord, draft: ArticleWikiDraft) {
    const keyPoints = draft.keyPoints.length > 0
        ? draft.keyPoints.map((item) => `- ${item}`).join("\n")
        : "- 暂无结构化要点"
    const entities = draft.entities.length > 0
        ? draft.entities.map((item) => `\`${item}\``).join("、")
        : "暂无"
    const questions = draft.questions.length > 0
        ? draft.questions.map((item) => `- ${item}`).join("\n")
        : "- 暂无"

    return [
        `# ${article.title}`,
        "",
        "## 摘要",
        draft.summary,
        "",
        "## 关键要点",
        keyPoints,
        "",
        "## 相关实体",
        entities,
        "",
        "## 可回答的问题",
        questions,
        "",
        "## 来源",
        `- 源文档 ID：${article.id}`,
        `- 最近更新：${formatDate(article.updatedAt) ?? ""}`,
    ].join("\n")
}

async function rebuildWikiIndex(db: Db, userId: number, knowledgeBaseId: number, knowledgeBaseName: string) {
    const pages = await db
        .select()
        .from(knowledgeBaseWikiPages)
        .where(and(eq(knowledgeBaseWikiPages.userId, userId), eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBaseId)))
        .orderBy(asc(knowledgeBaseWikiPages.kind), asc(knowledgeBaseWikiPages.title))
    const sourcePages = pages.filter((page) => page.kind === "source")
    const conceptPages = pages.filter((page) => page.kind !== "source" && page.kind !== "index")
    const contentMd = [
        `# ${knowledgeBaseName} Wiki 索引`,
        "",
        "这个页面是文档问答 Agent 的入口。回答问题时应先读取本索引，再按需读取具体 Wiki 页面；只有 Wiki 信息不足时才回看源文档。",
        "",
        "## 源文档页面",
        ...sourcePages.map((page) => `- [[${page.pageKey}]] ${page.title}：${page.summary || summarizePlainText(page.contentMd, 120)}`),
        "",
        "## 主题与答案页面",
        ...(conceptPages.length > 0
            ? conceptPages.map((page) => `- [[${page.pageKey}]] ${page.title}：${page.summary || summarizePlainText(page.contentMd, 120)}`)
            : ["- 暂无沉淀页面"]),
        "",
        "## 维护规则",
        "- 原始文档是真源，不要静默改写。",
        "- Wiki 页面可以通过补丁审批更新。",
        "- 回答必须说明依据来自哪些 Wiki 页面或源文档。",
    ].join("\n")

    const indexPage = await upsertWikiPage(db, {
        userId,
        knowledgeBaseId,
        pageKey: "index",
        title: `${knowledgeBaseName} Wiki 索引`,
        kind: "index",
        contentMd,
        summary: `收录 ${sourcePages.length} 个源文档页面，${conceptPages.length} 个主题/答案页面。`,
        frontmatter: { sourcePageCount: sourcePages.length, conceptPageCount: conceptPages.length },
        sourceRefs: [],
    })

    await db.delete(knowledgeBaseWikiLinks).where(eq(knowledgeBaseWikiLinks.fromPageId, indexPage.id))
    const linkValues = pages
        .filter((page) => page.pageKey !== "index")
        .map((page) => ({
            userId,
            knowledgeBaseId,
            fromPageId: indexPage.id,
            toPageKey: page.pageKey,
            linkType: "index",
        }))
    if (linkValues.length > 0) {
        await db.insert(knowledgeBaseWikiLinks).values(linkValues)
    }
    return indexPage
}

async function upsertWikiPage(db: Db, input: {
    userId: number
    knowledgeBaseId: number
    pageKey: string
    title: string
    kind: WikiPageKind | string
    contentMd: string
    summary?: string | null
    frontmatter?: unknown
    sourceRefs: Array<{ articleId: number; anchor?: string | null; note?: string | null }>
}) {
    const now = new Date()
    const normalizedPageKey = normalizePageKey(input.pageKey)
    const contentHash = stableHash(input.contentMd)
    const existing = await loadWikiPage(db, input.userId, input.knowledgeBaseId, normalizedPageKey)
    const values = {
        title: input.title,
        kind: input.kind,
        contentMd: input.contentMd,
        summary: input.summary ?? null,
        frontmatterJson: input.frontmatter === undefined ? null : JSON.stringify(input.frontmatter),
        contentHash,
        archivedAt: null,
        updatedAt: now,
    }
    const [page] = existing
        ? await db
            .update(knowledgeBaseWikiPages)
            .set({ ...values, version: existing.version + 1 })
            .where(and(eq(knowledgeBaseWikiPages.id, existing.id), eq(knowledgeBaseWikiPages.userId, input.userId)))
            .returning()
        : await db
            .insert(knowledgeBaseWikiPages)
            .values({
                userId: input.userId,
                knowledgeBaseId: input.knowledgeBaseId,
                pageKey: normalizedPageKey,
                ...values,
            })
            .returning()

    await db.delete(knowledgeBaseWikiSourceRefs).where(eq(knowledgeBaseWikiSourceRefs.pageId, page.id))
    if (input.sourceRefs.length > 0) {
        await db.insert(knowledgeBaseWikiSourceRefs).values(input.sourceRefs.map((ref) => ({
            pageId: page.id,
            articleId: ref.articleId,
            anchor: ref.anchor ?? null,
            note: ref.note ?? null,
            quoteHash: null,
        })))
    }
    return page
}

async function loadWikiPage(db: Db, userId: number, knowledgeBaseId: number, pageKey: string) {
    const [page] = await db
        .select()
        .from(knowledgeBaseWikiPages)
        .where(and(
            eq(knowledgeBaseWikiPages.userId, userId),
            eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBaseId),
            eq(knowledgeBaseWikiPages.pageKey, normalizePageKey(pageKey)),
        ))
        .limit(1)
    return page ?? null
}

async function listWikiPagesRaw(userId: number, knowledgeBaseId: number) {
    await assertKnowledgeBaseOwner(getDb(), userId, knowledgeBaseId)
    return await getDb()
        .select()
        .from(knowledgeBaseWikiPages)
        .where(and(eq(knowledgeBaseWikiPages.userId, userId), eq(knowledgeBaseWikiPages.knowledgeBaseId, knowledgeBaseId)))
        .orderBy(desc(knowledgeBaseWikiPages.updatedAt))
}

async function loadPatch(db: Db, userId: number, knowledgeBaseId: number, patchId: number) {
    const [patch] = await db
        .select()
        .from(knowledgeBaseWikiPatches)
        .where(and(
            eq(knowledgeBaseWikiPatches.id, patchId),
            eq(knowledgeBaseWikiPatches.userId, userId),
            eq(knowledgeBaseWikiPatches.knowledgeBaseId, knowledgeBaseId),
        ))
        .limit(1)
    if (!patch) {
        throw notFound("Wiki 补丁不存在")
    }
    return patch
}

async function logWikiEvent(db: Db, userId: number, knowledgeBaseId: number, eventType: string, pageId: number | null, threadId: number | null, payload: unknown) {
    await db.insert(knowledgeBaseWikiEventLogs).values({
        userId,
        knowledgeBaseId,
        eventType,
        pageId,
        threadId,
        payloadJson: JSON.stringify(payload),
    })
}

function buildArticleSourcePageKey(articleId: number) {
    return `source-${articleId}`
}

function toKnowledgeBaseLite(kb: KnowledgeBaseRecord) {
    return {
        id: String(kb.id),
        name: kb.name,
        description: kb.description,
        createdAt: formatDate(kb.createdAt),
        updatedAt: formatDate(kb.updatedAt),
    }
}

function parseJsonObject(raw: string | null | undefined) {
    if (!raw?.trim()) return null
    try {
        return JSON.parse(raw) as unknown
    } catch {
        return null
    }
}

function getFrontmatterSourceHash(page: KnowledgeBaseWikiPageRecord) {
    const frontmatter = parseJsonObject(page.frontmatterJson)
    if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
        return null
    }
    const value = (frontmatter as { sourceHash?: unknown }).sourceHash
    return typeof value === "string" ? value : null
}

function safeParseJsonObject(raw: string) {
    const jsonText = raw.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim()
    const start = jsonText.indexOf("{")
    const end = jsonText.lastIndexOf("}")
    if (start < 0 || end < start) {
        throw badRequest("模型没有返回合法 JSON")
    }
    return JSON.parse(jsonText.slice(start, end + 1)) as unknown
}

function normalizeStringList(values: unknown) {
    if (!Array.isArray(values)) return []
    return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))]
}

function summarizePlainText(markdown: string, maxLength: number) {
    const text = markdown
        .replace(/```[\s\S]*?```/g, " ")
        .replace(/`([^`]+)`/g, "$1")
        .replace(/!\[[^\]]*]\([^)]*\)/g, " ")
        .replace(/\[[^\]]*]\([^)]*\)/g, (value) => value.replace(/^\[|\]\([^)]*\)$/g, ""))
        .replace(/[#>*_\-~|]/g, " ")
        .replace(/\s+/g, " ")
        .trim()
    if (text.length <= maxLength) return text
    return `${text.slice(0, maxLength).trim()}...`
}

function extractMarkdownHeadings(markdown: string) {
    return markdown
        .split(/\r?\n/)
        .map((line) => line.match(/^#{1,4}\s+(.+)$/)?.[1]?.trim())
        .filter((value): value is string => Boolean(value))
}

function splitSentences(markdown: string) {
    return summarizePlainText(markdown, 1200)
        .split(/[。！？.!?]\s*/)
        .map((item) => item.trim())
        .filter(Boolean)
}

function scoreWikiPage(page: KnowledgeBaseWikiPageRecord, query: string) {
    return scoreSearchFields({
        title: page.title,
        summary: page.summary,
        content: page.contentMd,
        extra: page.pageKey,
    }, query)
}

function buildSimpleUnifiedDiff(oldText: string, newText: string, title: string) {
    const oldLines = oldText.split(/\r?\n/)
    const newLines = newText.split(/\r?\n/)
    const lines = [
        `--- ${title || "before"}`,
        `+++ ${title || "after"}`,
    ]
    const max = Math.max(oldLines.length, newLines.length)
    for (let index = 0; index < max; index += 1) {
        const before = oldLines[index]
        const after = newLines[index]
        if (before === after) {
            if (before !== undefined) lines.push(` ${before}`)
            continue
        }
        if (before !== undefined) lines.push(`-${before}`)
        if (after !== undefined) lines.push(`+${after}`)
    }
    return lines.join("\n")
}
