import crypto from "node:crypto";
import path from "node:path";
import fs from "node:fs/promises";
import matter from "gray-matter";
import { marked } from "marked";

const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : null;
const SOURCE_ARTICLES_DIR = path.join(ROOT, "src", "content", "articles");
const SOURCE_PUBLISHED_DIR = path.join(ROOT, "src", "content", "published");
export const ARTICLES_DIR = DATA_ROOT ? path.join(DATA_ROOT, "articles") : path.join(ROOT, "src", "content", "articles");
export const PUBLISHED_DIR = DATA_ROOT ? path.join(DATA_ROOT, "published") : path.join(ROOT, "src", "content", "published");
export const CATEGORIES_FILE = DATA_ROOT ? path.join(DATA_ROOT, "categories.json") : path.join(ROOT, "src", "content", "categories.json");
export const UPLOADS_DIR = DATA_ROOT ? path.join(DATA_ROOT, "uploads") : path.join(ROOT, "public", "uploads");
const DATA_SEED_MARKER = DATA_ROOT ? path.join(DATA_ROOT, ".content-initialized") : null;

const DEFAULT_CATEGORIES = ["学情指南", "学习方法", "家长课堂", "服务说明"];
const DEFAULT_AUTHOR = "启优学教研中心";
const CATEGORY_SLUGS: Record<string, string> = {
  学情指南: "learning-profile",
  学习方法: "learning-methods",
  家长课堂: "parent-guides",
  服务说明: "service-guides",
};
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

export type PublishStatus = "new-draft" | "edited-draft" | "live";

export interface StoredArticle {
  slug: string;
  title: string;
  date: string;
  updated: string;
  category: string;
  author: string;
  excerpt: string;
  readingMinutes: number;
  featured: boolean;
  status: "draft" | "published";
  body: string;
}
type StoredArticleMetadata = Omit<StoredArticle, "body">;

export interface ArticleMetadata {
  id: string;
  data: {
    title: string;
    slug: string;
    date: Date;
    updated: Date;
    category: string;
    author: string;
    excerpt: string;
    readingMinutes: number;
    featured: boolean;
    status: "published";
  };
}

export type Article = ArticleMetadata;
export interface PublishedArticleSource extends ArticleMetadata { body: string; }
export interface PublishedArticleDetail extends PublishedArticleSource { html: string; }

export interface CategoryStat {
  name: string;
  count: number;
}

export interface ContentSnapshot {
  restore: () => Promise<void>;
}

export class StoreError extends Error {
  status: number;
  details?: Record<string, unknown>;

  constructor(message: string, status = 400, details?: Record<string, unknown>) {
    super(message);
    this.status = status;
    this.details = details;
  }
}

export const safeSlug = (slug: unknown): string | null => typeof slug === "string" && SLUG_RE.test(slug) ? slug : null;

export const fmtDate = (value?: string | Date): string => {
  const date = value instanceof Date ? value : new Date(value || Date.now());
  return Number.isNaN(date.valueOf()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
};

const autoExcerpt = (body: string, fallback: string): string => {
  const plain = String(body || "")
    .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
    .replace(/<[^>]+>/g, " ")
    .replace(/[`*_>#~|\-]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
  const text = plain || String(fallback || "").trim();
  return text.length > 120 ? `${text.slice(0, 120).trim()}…` : text;
};

function serialize(data: StoredArticle, body: string): string {
  const quote = (value: unknown) => JSON.stringify(value == null ? "" : String(value));
  return [
    "---",
    `title: ${quote(data.title)}`,
    `slug: ${quote(data.slug)}`,
    `date: ${fmtDate(data.date)}`,
    `updated: ${fmtDate(data.updated || data.date)}`,
    `category: ${quote(data.category)}`,
    `author: ${quote(data.author || DEFAULT_AUTHOR)}`,
    `excerpt: ${quote(data.excerpt)}`,
    `readingMinutes: ${Math.max(1, Math.round(Number(data.readingMinutes) || 1))}`,
    `featured: ${Boolean(data.featured)}`,
    `status: ${quote(data.status || "draft")}`,
    "---",
    "",
    String(body || "").trim(),
    "",
  ].join("\n");
}

async function fileExists(file: string): Promise<boolean> {
  try { await fs.access(file); return true; } catch { return false; }
}

async function writeAtomic(file: string, content: string | Buffer): Promise<void> {
  await fs.mkdir(path.dirname(file), { recursive: true });
  const temporary = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
  try {
    await fs.writeFile(temporary, content);
    await fs.rename(temporary, file);
  } catch (error) {
    await fs.unlink(temporary).catch(() => {});
    throw error;
  }
}

async function ensureStore(): Promise<void> {
  await Promise.all([
    fs.mkdir(ARTICLES_DIR, { recursive: true }),
    fs.mkdir(PUBLISHED_DIR, { recursive: true }),
    fs.mkdir(UPLOADS_DIR, { recursive: true }),
  ]);
  if (DATA_ROOT && DATA_SEED_MARKER && !await fileExists(DATA_SEED_MARKER)) {
    const seedDirectory = async (source: string, target: string) => {
      const seeds = await fs.readdir(source).catch(() => [] as string[]);
      await Promise.all(seeds.filter((file) => file.endsWith(".md")).map(async (file) => {
        await writeAtomic(path.join(target, file), await fs.readFile(path.join(source, file)));
      }));
    };
    const [workingFiles, publishedFiles] = await Promise.all([fs.readdir(ARTICLES_DIR), fs.readdir(PUBLISHED_DIR)]);
    const hasExistingContent = [...workingFiles, ...publishedFiles].some((file) => file.endsWith(".md"));
    if (!hasExistingContent) {
      await Promise.all([
        seedDirectory(SOURCE_ARTICLES_DIR, ARTICLES_DIR),
        seedDirectory(SOURCE_PUBLISHED_DIR, PUBLISHED_DIR),
      ]);
    }
    await writeAtomic(DATA_SEED_MARKER, `${new Date().toISOString()}\n`);
  }
  if (!await fileExists(CATEGORIES_FILE)) {
    await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(DEFAULT_CATEGORIES, null, 2)}\n`);
  }
}

export async function getCategoryNames(): Promise<string[]> {
  await ensureStore();
  try {
    const parsed: unknown = JSON.parse(await fs.readFile(CATEGORIES_FILE, "utf8"));
    const names = Array.isArray(parsed)
      ? parsed.map((value) => String(value).replace(/\s+/g, " ").trim()).filter(Boolean)
      : [];
    return [...new Set(names)].length ? [...new Set(names)] : [...DEFAULT_CATEGORIES];
  } catch {
    return [...DEFAULT_CATEGORIES];
  }
}

async function saveCategories(categories: string[]): Promise<void> {
  await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(categories, null, 2)}\n`);
}

function normalizeCategoryName(value: unknown): string {
  const name = String(value || "").replace(/\s+/g, " ").trim();
  if (!name) throw new StoreError("请输入分类名称");
  if (name.length > 20) throw new StoreError("分类名称不能超过 20 个字");
  return name;
}

export async function addCategory(value: unknown): Promise<string[]> {
  const name = normalizeCategoryName(value);
  const categories = await getCategoryNames();
  if (categories.includes(name)) throw new StoreError("该分类已经存在", 409);
  const next = [...categories, name];
  await saveCategories(next);
  return next;
}

export async function readArticleFrom(directory: string, slug: string): Promise<StoredArticle> {
  const raw = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
  const { data, content } = matter(raw);
  return {
    slug,
    title: String(data.title || ""),
    date: fmtDate(data.date),
    updated: fmtDate(data.updated || data.date),
    category: String(data.category || ""),
    author: String(data.author || DEFAULT_AUTHOR),
    excerpt: String(data.excerpt || ""),
    readingMinutes: Math.max(1, Math.round(Number(data.readingMinutes) || 1)),
    featured: data.featured === true,
    status: data.status === "published" ? "published" : "draft",
    body: content.trim(),
  };
}

async function readFrontmatter(file: string): Promise<string | null> {
  const handle = await fs.open(file, "r");
  const chunkSize = 4096;
  const maxFrontmatterBytes = 64 * 1024;
  const chunks: Buffer[] = [];
  let raw = "";
  let position = 0;
  try {
    while (position < maxFrontmatterBytes) {
      const buffer = Buffer.alloc(Math.min(chunkSize, maxFrontmatterBytes - position));
      const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
      if (!bytesRead) break;
      chunks.push(buffer.subarray(0, bytesRead));
      raw = Buffer.concat(chunks).toString("utf8");
      const closing = raw.indexOf("\n---", 4);
      if (raw.startsWith("---") && closing >= 0) return `${raw.slice(0, closing + 4)}\n`;
      position += bytesRead;
    }
    return null;
  } finally {
    await handle.close();
  }
}

async function readArticleMetadataFrom(directory: string, slug: string): Promise<StoredArticleMetadata | null> {
  const frontmatter = await readFrontmatter(path.join(directory, `${slug}.md`));
  if (!frontmatter) return null;
  const { data } = matter(frontmatter);
  if (data.status !== "published") return null;
  return {
    slug,
    title: String(data.title || ""),
    date: fmtDate(data.date),
    updated: fmtDate(data.updated || data.date),
    category: String(data.category || ""),
    author: String(data.author || DEFAULT_AUTHOR),
    excerpt: String(data.excerpt || ""),
    readingMinutes: Math.max(1, Math.round(Number(data.readingMinutes) || 1)),
    featured: data.featured === true,
    status: "published",
  };
}

export const readWorkingArticle = (slug: string) => readArticleFrom(ARTICLES_DIR, slug);

async function markdownFiles(directory: string): Promise<string[]> {
  await ensureStore();
  return (await fs.readdir(directory)).filter((file) => file.endsWith(".md"));
}

async function snapshotDirectory(directory: string): Promise<Map<string, string>> {
  const snapshot = new Map<string, string>();
  for (const filename of await markdownFiles(directory)) {
    snapshot.set(filename, await fs.readFile(path.join(directory, filename), "utf8"));
  }
  return snapshot;
}

async function restoreDirectory(directory: string, snapshot: Map<string, string>): Promise<void> {
  for (const filename of await markdownFiles(directory)) {
    if (!snapshot.has(filename)) await fs.unlink(path.join(directory, filename));
  }
  for (const [filename, content] of snapshot) await writeAtomic(path.join(directory, filename), content);
}

export async function createContentSnapshot(): Promise<ContentSnapshot> {
  await ensureStore();
  const articles = await snapshotDirectory(ARTICLES_DIR);
  const published = await snapshotDirectory(PUBLISHED_DIR);
  const categories = await fs.readFile(CATEGORIES_FILE, "utf8");
  return {
    restore: async () => {
      await restoreDirectory(ARTICLES_DIR, articles);
      await restoreDirectory(PUBLISHED_DIR, published);
      await writeAtomic(CATEGORIES_FILE, categories);
    },
  };
}

const sameArticleContent = (left: StoredArticle, right: StoredArticle): boolean =>
  ["slug", "title", "date", "category", "author", "excerpt", "readingMinutes", "featured", "body"]
    .every((key) => String(left[key as keyof StoredArticle] ?? "") === String(right[key as keyof StoredArticle] ?? ""));

export async function getPublishStatus(article: StoredArticle): Promise<PublishStatus> {
  const liveFile = path.join(PUBLISHED_DIR, `${article.slug}.md`);
  if (!await fileExists(liveFile)) return "new-draft";
  const liveArticle = await readArticleFrom(PUBLISHED_DIR, article.slug);
  return sameArticleContent(article, liveArticle) ? "live" : "edited-draft";
}

export async function listWorkingArticles(): Promise<Array<Omit<StoredArticle, "body"> & { publishStatus: PublishStatus }>> {
  const articles = await Promise.all((await markdownFiles(ARTICLES_DIR)).map(async (file) => {
    const article = await readWorkingArticle(file.replace(/\.md$/, ""));
    const { body: _body, ...meta } = article;
    return { ...meta, publishStatus: await getPublishStatus(article) };
  }));
  return articles.sort((a, b) => b.updated.localeCompare(a.updated) || b.date.localeCompare(a.date));
}

export async function writeArticle(slug: string, input: Record<string, unknown>, isNew = false): Promise<StoredArticle> {
  await ensureStore();
  const file = path.join(ARTICLES_DIR, `${slug}.md`);
  if (isNew && await fileExists(file)) throw new StoreError("该网址标识已存在", 409);
  const existing = !isNew && await fileExists(file) ? await readWorkingArticle(slug) : null;
  const categories = await getCategoryNames();
  const body = String(input.body || "").trim();
  const normalized: StoredArticle = {
    title: String(input.title || "").trim(),
    slug,
    date: existing?.date || fmtDate(),
    updated: fmtDate(),
    category: categories.includes(String(input.category)) ? String(input.category) : categories[0],
    author: DEFAULT_AUTHOR,
    excerpt: autoExcerpt(body, String(input.title || "")),
    readingMinutes: Math.max(1, Math.ceil(body.replace(/\s+/g, "").length / 400)),
    featured: input.featured === true || existing?.featured === true,
    status: "draft",
    body,
  };
  const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
  if (await fileExists(liveFile)) {
    const liveArticle = await readArticleFrom(PUBLISHED_DIR, slug);
    if (sameArticleContent(normalized, liveArticle)) {
      normalized.status = "published";
      normalized.updated = liveArticle.updated;
    }
  }
  await writeAtomic(file, serialize(normalized, body));
  return normalized;
}

export async function generateSlug(category: unknown, date = fmtDate()): Promise<string> {
  await ensureStore();
  const prefix = CATEGORY_SLUGS[String(category)] || "silver-journal";
  const base = `${prefix}-${date.replaceAll("-", "")}`;
  let slug = base;
  let suffix = 2;
  while (await fileExists(path.join(ARTICLES_DIR, `${slug}.md`)) || await fileExists(path.join(PUBLISHED_DIR, `${slug}.md`))) {
    slug = `${base}-${suffix++}`;
  }
  return slug;
}

async function restoreFile(file: string, content: string | null): Promise<void> {
  if (content === null) await fs.unlink(file).catch(() => {});
  else await writeAtomic(file, content);
}

export async function publishArticle(slug: string): Promise<void> {
  const article = await readWorkingArticle(slug);
  const published: StoredArticle = { ...article, status: "published", updated: fmtDate() };
  const text = serialize(published, article.body);
  const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
  const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
  const previousDraft = await fs.readFile(draftFile, "utf8");
  const previousLive = await fileExists(liveFile) ? await fs.readFile(liveFile, "utf8") : null;
  try {
    await writeAtomic(draftFile, text);
    await writeAtomic(liveFile, text);
    clearPublicArticleCaches(slug);
  } catch (error) {
    await restoreFile(draftFile, previousDraft);
    await restoreFile(liveFile, previousLive);
    clearPublicArticleCaches(slug);
    throw error;
  }
}

export async function unpublishArticle(slug: string): Promise<void> {
  const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
  const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
  const previousDraft = await fs.readFile(draftFile, "utf8");
  const previousLive = await fs.readFile(liveFile, "utf8");
  const article = await readWorkingArticle(slug);
  try {
    await writeAtomic(draftFile, serialize({ ...article, status: "draft" }, article.body));
    await fs.unlink(liveFile);
    clearPublicArticleCaches(slug);
  } catch (error) {
    await restoreFile(draftFile, previousDraft);
    await restoreFile(liveFile, previousLive);
    clearPublicArticleCaches(slug);
    throw error;
  }
}

export async function deleteArticle(slug: string): Promise<void> {
  const files = [path.join(ARTICLES_DIR, `${slug}.md`), path.join(PUBLISHED_DIR, `${slug}.md`)];
  const backups: Array<{ file: string; content: string }> = [];
  for (const file of files) if (await fileExists(file)) backups.push({ file, content: await fs.readFile(file, "utf8") });
  if (!backups.length) throw new StoreError("文章不存在", 404);
  try {
    for (const { file } of backups) await fs.unlink(file);
    clearPublicArticleCaches(slug);
  } catch (error) {
    for (const { file, content } of backups) await writeAtomic(file, content);
    clearPublicArticleCaches(slug);
    throw error;
  }
}

export async function discardDraft(slug: string): Promise<{ removed: boolean; publishStatus: PublishStatus | null }> {
  const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
  const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
  if (await fileExists(liveFile)) {
    await writeAtomic(draftFile, await fs.readFile(liveFile));
    return { removed: false, publishStatus: "live" };
  }
  await fs.unlink(draftFile);
  return { removed: true, publishStatus: null };
}

async function categoryArticleFiles(category: string): Promise<Array<{ file: string; article: StoredArticle; raw: string }>> {
  const results: Array<{ file: string; article: StoredArticle; raw: string }> = [];
  for (const directory of [ARTICLES_DIR, PUBLISHED_DIR]) {
    for (const filename of await markdownFiles(directory)) {
      const slug = filename.replace(/\.md$/, "");
      const article = await readArticleFrom(directory, slug);
      if (article.category === category) {
        const file = path.join(directory, filename);
        results.push({ file, article, raw: await fs.readFile(file, "utf8") });
      }
    }
  }
  return results;
}

export async function getCategoryStats(): Promise<CategoryStat[]> {
  const categories = await getCategoryNames();
  const seen = new Map<string, Set<string>>(categories.map((name) => [name, new Set()]));
  for (const directory of [ARTICLES_DIR, PUBLISHED_DIR]) {
    for (const filename of await markdownFiles(directory)) {
      const slug = filename.replace(/\.md$/, "");
      const article = await readArticleFrom(directory, slug);
      if (!seen.has(article.category)) seen.set(article.category, new Set());
      seen.get(article.category)?.add(slug);
    }
  }
  return categories.map((name) => ({ name, count: seen.get(name)?.size || 0 }));
}

async function updateCategoryArticles(current: string, replacement: string, nextCategories: string[]): Promise<void> {
  const affected = await categoryArticleFiles(current);
  const previousCategories = await fs.readFile(CATEGORIES_FILE, "utf8");
  try {
    for (const item of affected) {
      const updated = { ...item.article, category: replacement };
      await writeAtomic(item.file, serialize(updated, updated.body));
    }
    await saveCategories(nextCategories);
    clearPublicArticleCaches();
  } catch (error) {
    for (const item of affected) await writeAtomic(item.file, item.raw);
    await writeAtomic(CATEGORIES_FILE, previousCategories);
    clearPublicArticleCaches();
    throw error;
  }
}

export async function renameCategory(currentValue: unknown, nextValue: unknown): Promise<string[]> {
  const current = normalizeCategoryName(currentValue);
  const nextName = normalizeCategoryName(nextValue);
  const categories = await getCategoryNames();
  if (!categories.includes(current)) throw new StoreError("分类不存在", 404);
  if (current !== nextName && categories.includes(nextName)) throw new StoreError("该分类已经存在", 409);
  if (current === nextName) return categories;
  const next = categories.map((name) => name === current ? nextName : name);
  await updateCategoryArticles(current, nextName, next);
  return next;
}

export async function removeCategory(value: unknown, replacementValue: unknown): Promise<string[]> {
  const name = normalizeCategoryName(value);
  const categories = await getCategoryNames();
  if (!categories.includes(name)) throw new StoreError("分类不存在", 404);
  if (categories.length <= 1) throw new StoreError("至少需要保留一个文章分类", 409);
  const affected = await categoryArticleFiles(name);
  const count = new Set(affected.map((item) => item.article.slug)).size;
  const replacement = String(replacementValue || "").trim();
  if (count > 0 && (!replacement || !categories.includes(replacement) || replacement === name)) {
    throw new StoreError("请先选择这些文章要迁移到的分类", 409, { requiresReplacement: true, count });
  }
  const next = categories.filter((category) => category !== name);
  await updateCategoryArticles(name, replacement, next);
  return next;
}

interface FileSignature { mtimeMs: number; size: number; }
interface CacheEntry<T> { signature: FileSignature; value: T; }
const MAX_PUBLIC_CACHE_ENTRIES = 256;
const metadataCache = new Map<string, CacheEntry<ArticleMetadata>>();
const sourceCache = new Map<string, CacheEntry<PublishedArticleSource>>();
const detailCache = new Map<string, CacheEntry<PublishedArticleDetail>>();
let markdownRenderCount = 0;

const sameSignature = (left: FileSignature, right: FileSignature) => left.mtimeMs === right.mtimeMs && left.size === right.size;
function setLimitedCache<T>(cache: Map<string, CacheEntry<T>>, key: string, value: CacheEntry<T>): void {
  cache.delete(key);
  cache.set(key, value);
  while (cache.size > MAX_PUBLIC_CACHE_ENTRIES) cache.delete(cache.keys().next().value as string);
}

function publicMetadata(article: StoredArticleMetadata): ArticleMetadata {
  return {
    id: article.slug,
    data: {
      title: article.title,
      slug: article.slug,
      date: new Date(article.date),
      updated: new Date(article.updated),
      category: article.category,
      author: article.author,
      excerpt: article.excerpt,
      readingMinutes: article.readingMinutes,
      featured: article.featured,
      status: "published",
    },
  };
}

async function publishedFileState(slug: string): Promise<{ file: string; signature: FileSignature } | null> {
  if (!safeSlug(slug)) return null;
  const file = path.join(PUBLISHED_DIR, `${slug}.md`);
  try {
    const stat = await fs.stat(file);
    return { file, signature: { mtimeMs: stat.mtimeMs, size: stat.size } };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
    throw error;
  }
}

export async function getPublishedArticleMetadata(slug: string): Promise<ArticleMetadata | null> {
  const state = await publishedFileState(slug);
  if (!state) return null;
  const cached = metadataCache.get(slug);
  if (cached && sameSignature(cached.signature, state.signature)) return cached.value;
  const article = await readArticleMetadataFrom(PUBLISHED_DIR, slug);
  if (!article) return null;
  const value = publicMetadata(article);
  setLimitedCache(metadataCache, slug, { signature: state.signature, value });
  return value;
}

export async function getPublishedArticles(): Promise<ArticleMetadata[]> {
  const files = await markdownFiles(PUBLISHED_DIR);
  const items = (await Promise.all(files.map((file) => getPublishedArticleMetadata(file.replace(/\.md$/, "")))))
    .filter((item): item is ArticleMetadata => item !== null);
  return items.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}

export async function getPublishedArticleSource(slug: string): Promise<PublishedArticleSource | null> {
  const state = await publishedFileState(slug);
  if (!state) return null;
  const cached = sourceCache.get(slug);
  if (cached && sameSignature(cached.signature, state.signature)) return cached.value;
  const article = await readArticleFrom(PUBLISHED_DIR, slug);
  if (article.status !== "published") return null;
  const value = { ...publicMetadata(article), body: article.body };
  setLimitedCache(sourceCache, slug, { signature: state.signature, value });
  return value;
}

export async function getPublishedArticle(slug: string): Promise<PublishedArticleDetail | null> {
  const state = await publishedFileState(slug);
  if (!state) return null;
  const cached = detailCache.get(slug);
  if (cached && sameSignature(cached.signature, state.signature)) return cached.value;
  const source = await getPublishedArticleSource(slug);
  if (!source) return null;
  markdownRenderCount += 1;
  const value = { ...source, html: await marked.parse(source.body) };
  setLimitedCache(detailCache, slug, { signature: state.signature, value });
  return value;
}

export function clearPublicArticleCaches(slug?: string): void {
  if (slug) {
    metadataCache.delete(slug);
    sourceCache.delete(slug);
    detailCache.delete(slug);
  } else {
    metadataCache.clear();
    sourceCache.clear();
    detailCache.clear();
  }
}

export function getArticleStoreDiagnostics(): { markdownRenderCount: number; metadataCacheSize: number; sourceCacheSize: number; detailCacheSize: number } {
  return { markdownRenderCount, metadataCacheSize: metadataCache.size, sourceCacheSize: sourceCache.size, detailCacheSize: detailCache.size };
}

export function resetArticleStoreDiagnostics(): void {
  markdownRenderCount = 0;
}

export async function saveUpload(dataUrl: unknown): Promise<string> {
  await ensureStore();
  const extensions: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif" };
  const match = String(dataUrl || "").match(/^data:([^;]+);base64,(.+)$/s);
  if (!match || !extensions[match[1]]) throw new StoreError("仅支持 PNG、JPG、WEBP 或 GIF 图片");
  const buffer = Buffer.from(match[2], "base64");
  if (buffer.length > 20 * 1024 * 1024) throw new StoreError("图片不能超过 20MB", 413);
  const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${extensions[match[1]]}`;
  await writeAtomic(path.join(UPLOADS_DIR, name), buffer);
  return `/uploads/${name}`;
}

export async function removeUpload(url: unknown): Promise<void> {
  const match = String(url || "").match(/^\/uploads\/([a-zA-Z0-9._-]+)$/);
  if (!match) throw new StoreError("图片地址不合法");
  await fs.unlink(path.join(UPLOADS_DIR, match[1])).catch((error: NodeJS.ErrnoException) => {
    if (error.code !== "ENOENT") throw error;
  });
}
