import "server-only";

import type { Prisma } from "@/generated/prisma/client";
import { db } from "@/lib/db";
import {
  isContentEmpty,
  readingMinutes,
  sanitizeContentHtml,
} from "@/lib/sanitize";
import { blogPath, revalidateSite } from "@/lib/site";
import { slugify } from "@/lib/slug";
import { cleanText } from "@/lib/utils";
import type { BlogDefaults } from "@/features/settings/schemas";

import {
  blogFormSchema,
  type BlogEditorMeta,
  type BlogFormValues,
} from "./schemas";

type Tx = Prisma.TransactionClient;

/** Relations the editor needs to rebuild its form. */
export const blogEditorInclude = {
  faqs: { orderBy: { position: "asc" } },
  tags: { include: { tag: true }, orderBy: { createdAt: "asc" } },
  relatedBlogs: { orderBy: { position: "asc" }, select: { relatedBlogId: true } },
  _count: { select: { revisions: true } },
} satisfies Prisma.BlogInclude;

export type BlogWithRelations = Prisma.BlogGetPayload<{
  include: typeof blogEditorInclude;
}>;

// ---------------------------------------------------------------------------
// Mapping
// ---------------------------------------------------------------------------

export function toFormValues(blog: BlogWithRelations): BlogFormValues {
  return {
    title: blog.title,
    slug: blog.slug,
    excerpt: blog.excerpt,
    content: blog.content,
    featuredImage: blog.featuredImage ?? "",
    featuredImageAlt: blog.featuredImageAlt ?? "",
    featuredImageTitle: blog.featuredImageTitle ?? "",
    categoryId: blog.categoryId,
    authorId: blog.authorId,
    tags: blog.tags.map(({ tag }) => tag.name),
    publishedAt: blog.publishedAt?.toISOString() ?? null,
    seoTitle: blog.seoTitle ?? "",
    metaDescription: blog.metaDescription ?? "",
    canonicalUrl: blog.canonicalUrl ?? "",
    focusKeyword: blog.focusKeyword ?? "",
    ogTitle: blog.ogTitle ?? "",
    ogDescription: blog.ogDescription ?? "",
    ogImage: blog.ogImage ?? "",
    robotsIndex: blog.robotsIndex,
    robotsFollow: blog.robotsFollow,
    faqs: blog.faqs.map(({ id, question, answer }) => ({ id, question, answer })),
    faqSchemaEnabled: blog.faqSchemaEnabled,
    ctaEnabled: blog.ctaEnabled,
    ctaTitle: blog.ctaTitle ?? "",
    ctaDescription: blog.ctaDescription ?? "",
    ctaButtonText: blog.ctaButtonText ?? "",
    ctaButtonUrl: blog.ctaButtonUrl ?? "",
    ctaImage: blog.ctaImage ?? "",
    relatedMode: blog.relatedMode,
    relatedBlogIds: blog.relatedBlogs.map(({ relatedBlogId }) => relatedBlogId),
  };
}

export function toEditorMeta(
  blog: Pick<
    BlogWithRelations,
    | "id"
    | "status"
    | "slug"
    | "version"
    | "publishedAt"
    | "scheduledAt"
    | "updatedAt"
    | "autosavedAt"
  > & { _count: { revisions: number } },
): BlogEditorMeta {
  return {
    id: blog.id,
    status: blog.status,
    slug: blog.slug,
    version: blog.version,
    publishedAt: blog.publishedAt?.toISOString() ?? null,
    scheduledAt: blog.scheduledAt?.toISOString() ?? null,
    updatedAt: blog.updatedAt.toISOString(),
    autosavedAt: blog.autosavedAt?.toISOString() ?? null,
    liveSlug: blog.status === "DRAFT" ? null : blog.slug,
    revisionCount: blog._count.revisions,
  };
}

/** A blank article, prefilled from the Blog defaults settings. */
export function emptyFormValues(defaults: BlogDefaults): BlogFormValues {
  return {
    title: "",
    slug: "",
    excerpt: "",
    content: "",
    featuredImage: "",
    featuredImageAlt: "",
    featuredImageTitle: "",
    categoryId: defaults.defaultCategoryId,
    authorId: defaults.defaultAuthorId,
    tags: [],
    publishedAt: null,
    seoTitle: "",
    metaDescription: "",
    canonicalUrl: "",
    focusKeyword: "",
    ogTitle: "",
    ogDescription: "",
    ogImage: "",
    robotsIndex: true,
    robotsFollow: true,
    faqs: [],
    faqSchemaEnabled: defaults.faqSchemaEnabled,
    ctaEnabled: defaults.ctaEnabled,
    ctaTitle: defaults.ctaTitle,
    ctaDescription: defaults.ctaDescription,
    ctaButtonText: defaults.ctaButtonText,
    ctaButtonUrl: defaults.ctaButtonUrl,
    ctaImage: defaults.ctaImage ?? "",
    relatedMode: "AUTOMATIC",
    relatedBlogIds: [],
  };
}

/**
 * Parses a stored snapshot (autosave or revision). Snapshots written before a
 * field existed are filled in from `fallback`.
 */
export function parseSnapshot(value: unknown, fallback: BlogFormValues) {
  if (!value || typeof value !== "object") return null;
  const parsed = blogFormSchema.safeParse({ ...fallback, ...value });
  return parsed.success ? parsed.data : null;
}

// ---------------------------------------------------------------------------
// Normalizing input
// ---------------------------------------------------------------------------

/** Cleans every field. HTML is sanitized here, so nothing unsafe is ever stored. */
export function normalizeValues(values: BlogFormValues): BlogFormValues {
  const content = sanitizeContentHtml(values.content);
  const seenTags = new Set<string>();

  return {
    ...values,
    title: cleanText(values.title),
    slug: values.slug.trim().toLowerCase(),
    excerpt: cleanText(values.excerpt),
    content: isContentEmpty(content) ? "" : content,
    featuredImage: values.featuredImage.trim(),
    featuredImageAlt: cleanText(values.featuredImageAlt),
    featuredImageTitle: cleanText(values.featuredImageTitle),
    tags: values.tags
      .map(cleanText)
      .filter((tag) => {
        const key = slugify(tag);
        if (!key || seenTags.has(key)) return false;
        seenTags.add(key);
        return true;
      }),
    seoTitle: cleanText(values.seoTitle),
    metaDescription: cleanText(values.metaDescription),
    canonicalUrl: values.canonicalUrl.trim(),
    focusKeyword: cleanText(values.focusKeyword),
    ogTitle: cleanText(values.ogTitle),
    ogDescription: cleanText(values.ogDescription),
    ogImage: values.ogImage.trim(),
    faqs: values.faqs
      .map((faq) => ({
        id: faq.id,
        question: cleanText(faq.question),
        answer: cleanText(faq.answer),
      }))
      .filter((faq) => faq.question || faq.answer),
    ctaTitle: cleanText(values.ctaTitle),
    ctaDescription: cleanText(values.ctaDescription),
    ctaButtonText: cleanText(values.ctaButtonText),
    ctaButtonUrl: values.ctaButtonUrl.trim(),
    ctaImage: values.ctaImage.trim(),
    relatedBlogIds: [...new Set(values.relatedBlogIds)],
  };
}

const orNull = (value: string) => value || null;

/** Blog columns for a set of values (relations are written separately). */
export function blogColumns(values: BlogFormValues) {
  return {
    title: values.title,
    slug: values.slug,
    excerpt: values.excerpt,
    content: values.content,
    readingMinutes: readingMinutes(values.content),
    featuredImage: orNull(values.featuredImage),
    featuredImageAlt: orNull(values.featuredImageAlt),
    featuredImageTitle: orNull(values.featuredImageTitle),
    categoryId: values.categoryId,
    authorId: values.authorId,
    seoTitle: orNull(values.seoTitle),
    metaDescription: orNull(values.metaDescription),
    canonicalUrl: orNull(values.canonicalUrl),
    focusKeyword: orNull(values.focusKeyword),
    ogTitle: orNull(values.ogTitle),
    ogDescription: orNull(values.ogDescription),
    ogImage: orNull(values.ogImage),
    robotsIndex: values.robotsIndex,
    robotsFollow: values.robotsFollow,
    faqSchemaEnabled: values.faqSchemaEnabled,
    ctaEnabled: values.ctaEnabled,
    ctaTitle: orNull(values.ctaTitle),
    ctaDescription: orNull(values.ctaDescription),
    ctaButtonText: orNull(values.ctaButtonText),
    ctaButtonUrl: orNull(values.ctaButtonUrl),
    ctaImage: orNull(values.ctaImage),
    relatedMode: values.relatedMode,
  } satisfies Prisma.BlogUncheckedUpdateInput;
}

/** Drops category, author and related IDs that don't exist, so a stale form can't break a save. */
export async function resolveReferences(values: BlogFormValues, blogId: string | null) {
  const [category, author, related] = await Promise.all([
    values.categoryId
      ? db.category.findUnique({ where: { id: values.categoryId }, select: { id: true } })
      : null,
    values.authorId
      ? db.author.findUnique({ where: { id: values.authorId }, select: { id: true } })
      : null,
    values.relatedBlogIds.length
      ? db.blog.findMany({
          where: { id: { in: values.relatedBlogIds } },
          select: { id: true },
        })
      : [],
  ]);
  const existingRelated = new Set(related.map(({ id }) => id));

  return {
    ...values,
    categoryId: category?.id ?? null,
    authorId: author?.id ?? null,
    relatedBlogIds: values.relatedBlogIds.filter(
      (id) => existingRelated.has(id) && id !== blogId,
    ),
  };
}

// ---------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------

/** Replaces FAQs, tags and related articles to match `values`. */
export async function writeRelations(tx: Tx, blogId: string, values: BlogFormValues) {
  await tx.fAQ.deleteMany({ where: { blogId } });
  if (values.faqs.length) {
    await tx.fAQ.createMany({
      data: values.faqs.map((faq, position) => ({
        blogId,
        question: faq.question,
        answer: faq.answer,
        position,
      })),
    });
  }

  await tx.blogTag.deleteMany({ where: { blogId } });
  if (values.tags.length) {
    // One at a time: a transaction runs on a single connection, which can't take parallel queries.
    const tags: { id: string }[] = [];
    for (const name of values.tags) {
      tags.push(
        await tx.tag.upsert({
          where: { slug: slugify(name, 60) },
          create: { name, slug: slugify(name, 60) },
          update: {},
          select: { id: true },
        }),
      );
    }
    await tx.blogTag.createMany({
      data: tags.map(({ id }) => ({ blogId, tagId: id })),
      skipDuplicates: true,
    });
  }

  await tx.relatedBlog.deleteMany({ where: { blogId } });
  if (values.relatedBlogIds.length) {
    await tx.relatedBlog.createMany({
      data: values.relatedBlogIds.map((relatedBlogId, position) => ({
        blogId,
        relatedBlogId,
        position,
      })),
    });
  }
}

export type RevisionReason =
  | "published"
  | "updated"
  | "unpublished"
  | "restored"
  | "auto-published";

export async function createRevision(
  tx: Tx,
  blogId: string,
  values: BlogFormValues,
  reason: RevisionReason,
  editedById: string | null,
) {
  await tx.blogRevision.create({
    data: {
      blogId,
      title: values.title,
      slug: values.slug,
      excerpt: values.excerpt,
      content: values.content,
      snapshot: values as unknown as Prisma.InputJsonValue,
      reason,
      editedById,
    },
  });
}

/**
 * Keeps old links working after a live article's slug changes: the old path
 * redirects to the new one, earlier redirects are re-pointed so there are no
 * chains, and any redirect sitting on the new path is removed.
 */
export async function recordSlugChange(
  tx: Tx,
  blogId: string,
  oldSlug: string,
  newSlug: string,
) {
  if (oldSlug === newSlug) return;
  const from = blogPath(oldSlug);
  const to = blogPath(newSlug);

  await tx.redirect.deleteMany({ where: { fromPath: to } });
  await tx.redirect.updateMany({ where: { toPath: from }, data: { toPath: to } });
  await tx.redirect.upsert({
    where: { fromPath: from },
    create: { fromPath: from, toPath: to, blogId, permanent: true },
    update: { toPath: to, blogId, permanent: true },
  });
}

/**
 * Removes redirects that would shadow an article's own URL, e.g. when a new
 * article takes a slug an older one used to have.
 */
export async function releaseSlugPath(tx: Tx, slug: string) {
  await tx.redirect.deleteMany({ where: { fromPath: blogPath(slug) } });
}

// ---------------------------------------------------------------------------
// Scheduling
// ---------------------------------------------------------------------------

/**
 * Flips scheduled articles whose time has come to Published. The public API
 * already treats them as live, so this keeps the CMS in step and records a
 * revision. Safe to call often and concurrently.
 */
export async function publishDueScheduledPosts() {
  const due = await db.blog.findMany({
    where: { status: "SCHEDULED", scheduledAt: { lte: new Date() } },
    include: blogEditorInclude,
    take: 100,
  });
  const published: string[] = [];

  for (const blog of due) {
    const updated = await db.$transaction(async (tx) => {
      const { count } = await tx.blog.updateMany({
        where: { id: blog.id, status: "SCHEDULED", version: blog.version },
        data: {
          status: "PUBLISHED",
          publishedAt: blog.scheduledAt,
          scheduledAt: null,
          version: { increment: 1 },
        },
      });
      if (count === 0) return false;
      await createRevision(tx, blog.id, toFormValues(blog), "auto-published", null);
      return true;
    });
    if (updated) published.push(blog.slug);
  }

  if (published.length) revalidateSite(published);
  return published;
}
