/**
 * Imports the WordPress blog into this CMS: posts, categories, authors, tags,
 * FAQs and every image the posts use.
 *
 *   npm run import:wordpress -- --dry-run      # download and check everything, write nothing
 *   npm run import:wordpress                   # import
 *   npm run import:wordpress -- --only slug-a,slug-b
 *   npm run import:wordpress -- --overwrite    # replace articles that already exist
 *   npm run import:wordpress -- --author-map secondline_number=andrew-miller
 *   npm run import:wordpress -- --export-json wordpress-import/blog-export.json
 *
 * --export-json writes every post to a blog export file instead of importing
 * (format: src/features/blog-import/format.ts, docs/blog-export-format.md).
 * It reads WordPress only: no database writes, no image downloads. Images
 * keep their WordPress URLs. Load the file with "Import JSON" in the admin
 * or `npm run import:json`.
 *
 * Reads the public WordPress REST API, so published posts need no WordPress
 * login. With WP_USERNAME and WP_APP_PASSWORD (an application password) set,
 * scheduled posts are imported as scheduled, and drafts and private posts as
 * drafts.
 *
 * Articles are saved through the same helpers as the editor (sanitizer,
 * normalizing, revisions), and images go through the configured storage
 * driver and into the media library. Slugs are kept, so every article stays
 * at /blog/<slug>/. Articles that already exist are skipped unless
 * --overwrite is given, so the import can be re-run safely.
 *
 * Writes to --out (default wordpress-import/):
 *   report.json            every post, what happened to it and anything to check
 *   url-checklist.csv      old and new URL of each article, to compare after launch
 *   image-redirects.csv    old image URLs → new ones, for Cloudflare Bulk Redirects
 *   archive-redirects.csv  category, author and feed URLs → the blog
 */
import "dotenv/config";

import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";

import { Element, isTag, Text, type AnyNode, type ChildNode } from "domhandler";
import { DomUtils, parseDocument } from "htmlparser2";

import {
  BLOG_EXPORT_FORMAT,
  BLOG_EXPORT_VERSION,
  blogExportSchema,
  type BlogExportInput,
} from "@/features/blog-import/format";
import { blogFormSchema, type BlogFormValues } from "@/features/blogs/schemas";
import {
  blogColumns,
  createRevision,
  emptyFormValues,
  normalizeValues,
  releaseSlugPath,
  writeRelations,
} from "@/features/blogs/service";
import { blogDefaultsSchema } from "@/features/settings/schemas";
import { Prisma, type ContentStatus } from "@/generated/prisma/client";
import { db } from "@/lib/db";
import { env } from "@/lib/env";
import { validateImage } from "@/lib/media/validation";
import { isContentEmpty, sanitizeContentHtml } from "@/lib/sanitize";
import { absoluteMediaUrl, blogPath, siteCacheTags } from "@/lib/site";
import { slugify } from "@/lib/slug";
import { storage } from "@/lib/storage";

// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    "dry-run": { type: "boolean", default: false },
    only: { type: "string" },
    overwrite: { type: "boolean", default: false },
    "author-map": { type: "string", multiple: true, default: [] },
    source: { type: "string", default: "https://secondlinenumber.com" },
    out: { type: "string", default: "wordpress-import" },
    concurrency: { type: "string", default: "4" },
    "export-json": { type: "string" },
  },
});

const exportJsonPath = args["export-json"];
const dryRun = args["dry-run"] || Boolean(exportJsonPath);
const source = args.source.replace(/\/+$/, "");
const sourceHost = new URL(source).hostname.replace(/^www\./, "");
const onlySlugs = new Set(
  (args.only ?? "")
    .split(",")
    .map((slug) => slug.trim())
    .filter(Boolean),
);
const concurrency = Math.max(1, Math.min(10, Number(args.concurrency) || 4));

/** WordPress user slug → CMS author slug, from --author-map wp=cms. */
const authorMap = new Map(
  args["author-map"].map((pair) => {
    const [from, to] = pair.split("=").map((part) => part.trim());
    if (!from || !to)
      throw new Error(`--author-map expects wp-slug=cms-slug, got "${pair}".`);
    return [from, to] as const;
  }),
);

const wpAuth =
  process.env.WP_USERNAME && process.env.WP_APP_PASSWORD
    ? `Basic ${Buffer.from(
        `${process.env.WP_USERNAME}:${process.env.WP_APP_PASSWORD.replace(/\s+/g, "")}`,
      ).toString("base64")}`
    : null;

const USER_AGENT = "SecondLineNumber-CMS-Import/1.0";

// ---------------------------------------------------------------------------
// WordPress API
// ---------------------------------------------------------------------------

type Rendered = { rendered: string };

type WpPost = {
  id: number;
  date_gmt: string;
  modified_gmt: string;
  slug: string;
  status: string;
  link: string;
  title: Rendered;
  content: Rendered;
  excerpt: Rendered;
  author: number;
  featured_media: number;
  categories: number[];
  tags: number[];
  yoast_head_json?: {
    title?: string;
    description?: string;
    canonical?: string;
    og_title?: string;
    og_description?: string;
    og_image?: { url?: string }[];
    robots?: { index?: string; follow?: string };
  };
};

type WpMedia = {
  id: number;
  source_url: string;
  alt_text: string;
  title: Rendered;
  media_details?: { sizes?: Record<string, { source_url: string }> };
};

type WpUser = {
  id: number;
  name: string;
  slug: string;
  description: string;
  link?: string;
  avatar_urls?: Record<string, string>;
};

type WpTerm = {
  id: number;
  name: string;
  slug: string;
  description: string;
  link?: string;
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const errorMessage = (error: unknown) =>
  error instanceof Error ? error.message : String(error);

/** GET with a timeout and up to three tries on network errors and 5xx answers. */
async function request(url: string, accept: string) {
  const isApi = url.startsWith(`${source}/wp-json/`);
  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch(url, {
        headers: {
          accept,
          "user-agent": USER_AGENT,
          ...(isApi && wpAuth && { authorization: wpAuth }),
        },
        signal: AbortSignal.timeout(30_000),
      });
      if (response.status >= 500 && attempt < 3)
        throw new Error(`HTTP ${response.status}`);
      return response;
    } catch (error) {
      if (attempt >= 3) throw new Error(`${url}: ${errorMessage(error)}`);
      await sleep(1000 * attempt);
    }
  }
}

async function wpJson<T>(
  endpoint: string,
  params: Record<string, string> = {},
) {
  const url = `${source}/wp-json/wp/v2/${endpoint}?${new URLSearchParams(params)}`;
  const response = await request(url, "application/json");
  if (!response.ok)
    throw new Error(`WordPress answered ${response.status} to ${url}`);
  return {
    body: (await response.json()) as T,
    totalPages: Number(response.headers.get("x-wp-totalpages") ?? "1") || 1,
  };
}

/** Every item of a collection, 100 to a page. */
async function wpAll<T>(endpoint: string, params: Record<string, string> = {}) {
  const items: T[] = [];
  for (let page = 1, total = 1; page <= total; page++) {
    const result = await wpJson<T[]>(endpoint, {
      ...params,
      per_page: "100",
      page: String(page),
    });
    items.push(...result.body);
    total = result.totalPages;
  }
  return items;
}

/** Items by ID, in batches of 100. */
async function wpByIds<T>(endpoint: string, ids: number[]) {
  const unique = [...new Set(ids.filter((id) => id > 0))];
  const items: T[] = [];
  for (let start = 0; start < unique.length; start += 100) {
    const batch = unique.slice(start, start + 100);
    items.push(
      ...(await wpAll<T>(endpoint, {
        include: batch.join(","),
        orderby: "include",
      })),
    );
  }
  return items;
}

// ---------------------------------------------------------------------------
// Text helpers
// ---------------------------------------------------------------------------

const collapse = (value: string) => value.replace(/[\s\u00a0]+/g, " ").trim();

/** Plain text of a WordPress HTML fragment, entities decoded. */
const decodeHtml = (html: string) =>
  collapse(DomUtils.textContent(parseDocument(html)));

const nodeText = (node: AnyNode | AnyNode[]) =>
  collapse(DomUtils.textContent(node));

function truncate(value: string, max: number) {
  if (value.length <= max) return value;
  const cut = value.slice(0, max - 1);
  return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max / 2)).trimEnd()}…`;
}

/** "second-phone-number-inner" (an alt text WordPress copied from the file name) → "Second phone number". */
function readableAlt(alt: string) {
  const value = alt.trim();
  if (!/^[\w-]+$/.test(value) || !/[-_]/.test(value)) return value;
  const words = value
    .split(/[-_]+/)
    .filter(
      (word) =>
        word &&
        !/^(inner|thumb|thum|banner|img|image|\d+|\d+x\d+)$/i.test(word),
    );
  if (!words.length) return "";
  const text = words.join(" ").toLowerCase();
  return text.charAt(0).toUpperCase() + text.slice(1);
}

const isFileNameLike = (value: string) => /^[\w-]+$/.test(value.trim());

function parseUrl(value: string | undefined) {
  if (!value) return null;
  try {
    const url = new URL(value.trim(), source);
    return /^https?:$/.test(url.protocol) ? url : null;
  } catch {
    return null;
  }
}

const sameSite = (url: URL) =>
  url.hostname.replace(/^www\./, "") === sourceHost;

/** The full-size upload behind a resized copy: "photo-300x180.webp" → "photo.webp". */
const originalUploadUrl = (url: string) =>
  url.replace(/-\d+x\d+(\.[a-z0-9]+)$/i, "$1");

const csvCell = (value: string | number) =>
  /[",\n]/.test(String(value))
    ? `"${String(value).replace(/"/g, '""')}"`
    : String(value);
const csvLine = (cells: (string | number)[]) => cells.map(csvCell).join(",");

async function mapPool<T, R>(
  items: T[],
  limit: number,
  task: (item: T) => Promise<R>,
) {
  const results: R[] = new Array(items.length);
  let next = 0;
  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, async () => {
      while (next < items.length) {
        const index = next++;
        results[index] = await task(items[index]);
      }
    }),
  );
  return results;
}

// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------

type StoredImage = {
  url: string;
  width: number | null;
  height: number | null;
  reused: boolean;
};

type ImageEntry = {
  /** URL of the full-size original; identifies the image. */
  key: string;
  /** URLs to download, best first. */
  candidates: string[];
  /** Every old URL of this image (resized copies included), for the redirect list. */
  variants: Set<string>;
  wpAlt: string;
  wpTitle: string;
  usedBy: Set<string>;
  stored?: StoredImage;
  error?: string;
};

const images = new Map<string, ImageEntry>();
const mediaById = new Map<number, WpMedia>();

function registerImage(src: string, usedBy: string, media?: WpMedia) {
  const absolute = new URL(src, source).href;
  const original = media?.source_url ?? originalUploadUrl(absolute);
  let entry = images.get(original);
  if (!entry) {
    entry = {
      key: original,
      candidates: [...new Set([original, absolute])],
      variants: new Set([original]),
      wpAlt: media?.alt_text ?? "",
      wpTitle: media ? decodeHtml(media.title.rendered) : "",
      usedBy: new Set(),
    };
    images.set(original, entry);
  }
  entry.variants.add(absolute);
  for (const size of Object.values(media?.media_details?.sizes ?? {})) {
    entry.variants.add(size.source_url);
  }
  entry.usedBy.add(usedBy);
  return entry;
}

/** The library item for an <img>, when its wp-image-<id> class points at the same file. */
function mediaForImg(img: Element) {
  const id = Number(/\bwp-image-(\d+)\b/.exec(img.attribs.class ?? "")?.[1]);
  const media = mediaById.get(id);
  const src = parseUrl(img.attribs.src)?.href;
  if (!media || !src) return undefined;
  return originalUploadUrl(src) === originalUploadUrl(media.source_url)
    ? media
    : undefined;
}

/** "2026/10/name.webp" under the WordPress upload folder, kept so files stay recognisable. */
function storageKeyFor(url: string, extension: string, attempt: number) {
  const { pathname } = new URL(url);
  const match = /\/(\d{4})\/(\d{2})\/([^/]+)$/.exec(pathname);
  const now = new Date();
  const year = match?.[1] ?? String(now.getUTCFullYear());
  const month = match?.[2] ?? String(now.getUTCMonth() + 1).padStart(2, "0");
  const file = decodeURIComponent(
    match?.[3] ?? pathname.split("/").pop() ?? "image",
  );
  const base = slugify(file.replace(/\.[^.]+$/, ""), 80) || "image";
  return `${year}/${month}/${base}${attempt > 1 ? `-${attempt}` : ""}.${extension}`;
}

/** Where an upload would be served from, shown in dry runs. */
function predictedUrl(key: string) {
  const config = env();
  return config.STORAGE_DRIVER === "s3"
    ? `${(config.S3_PUBLIC_URL ?? "").replace(/\/+$/, "")}/${key}`
    : `/uploads/${key}`;
}

const maxUploadBytes = () => env().MEDIA_MAX_UPLOAD_MB * 1024 * 1024;

/**
 * Downloads an image, checks it like an upload, and stores it with a media
 * library record. A file imported by an earlier run is reused.
 */
async function storeImage(entry: ImageEntry): Promise<StoredImage> {
  let lastError = "no URL to download";
  for (const candidate of entry.candidates) {
    try {
      const response = await request(candidate, "image/*");
      if (!response.ok) {
        lastError = `HTTP ${response.status} for ${candidate}`;
        continue;
      }
      const bytes = new Uint8Array(await response.arrayBuffer());
      // The bytes decide the type, as for uploads; WordPress's header is not checked.
      const image = validateImage(bytes, "", maxUploadBytes());
      const filename = decodeURIComponent(
        new URL(candidate).pathname.split("/").pop() ?? "image",
      );

      for (let attempt = 1; attempt <= 20; attempt++) {
        const storageKey = storageKeyFor(candidate, image.extension, attempt);
        const existing = await db.media.findUnique({ where: { storageKey } });
        if (existing) {
          if (
            existing.filename === filename &&
            existing.size === bytes.byteLength
          ) {
            return {
              url: existing.url,
              width: existing.width,
              height: existing.height,
              reused: true,
            };
          }
          continue;
        }

        if (dryRun) {
          return {
            url: predictedUrl(storageKey),
            width: image.width,
            height: image.height,
            reused: false,
          };
        }

        const { url } = await (
          await storage()
        ).put(storageKey, bytes, image.mimeType);
        await db.media.create({
          data: {
            filename,
            storageKey,
            url,
            mimeType: image.mimeType,
            size: bytes.byteLength,
            width: image.width,
            height: image.height,
            altText: readableAlt(entry.wpAlt),
            title:
              entry.wpTitle && !isFileNameLike(entry.wpTitle)
                ? entry.wpTitle
                : null,
          },
        });
        return { url, width: image.width, height: image.height, reused: false };
      }
      lastError = `no free storage key for ${candidate}`;
    } catch (error) {
      lastError = errorMessage(error);
    }
  }
  throw new Error(lastError);
}

// ---------------------------------------------------------------------------
// Content conversion
// ---------------------------------------------------------------------------

type Faq = { question: string; answer: string };

type Figure = {
  img: Element;
  entry: ImageEntry;
  alt: string;
  width?: string;
  height?: string;
};

type ConvertedContent = {
  /** The parsed article, still holding WordPress image URLs until images are stored. */
  root: Element;
  figures: Figure[];
  /** Links to image files, rewritten once the image is stored. */
  imageLinks: { link: Element; entry: ImageEntry }[];
  /** The banner the article opened with, removed from the text to become the featured image. */
  leading: Figure | null;
  faqs: Faq[];
  warnings: string[];
};

const BLOCK_TAGS = new Set([
  "p",
  "h1",
  "h2",
  "h3",
  "h4",
  "h5",
  "h6",
  "ul",
  "ol",
  "li",
  "table",
  "figure",
  "blockquote",
  "div",
  "pre",
  "hr",
  "section",
  "article",
  "header",
  "footer",
  "aside",
]);

/** Wrappers that may sit around an image without being content themselves. */
const IMAGE_WRAPPERS = new Set(["a", "span", "strong", "b", "em", "i"]);

const isBlank = (node: AnyNode) =>
  (node.type === "text" && !(node as Text).data.replace(/[\s\u00a0]/g, "")) ||
  (isTag(node) && node.name === "br");

const findAll = (root: Element, test: (el: Element) => boolean) =>
  DomUtils.findAll(test, root.children);

function createElement(
  name: string,
  attribs: Record<string, string>,
  children: AnyNode[] = [],
) {
  const element = new Element(name, attribs);
  // Copied first: appending moves a node out of the array it came from.
  for (const child of [...children])
    DomUtils.appendChild(element, child as ChildNode);
  return element;
}

/** Wraps runs of text and inline elements sitting directly in `root` in <p>s. */
function wrapLooseText(root: Element) {
  let run: ChildNode[] = [];
  const flush = () => {
    if (run.some((node) => !isBlank(node))) {
      const p = createElement("p", {});
      DomUtils.prepend(run[0], p);
      for (const node of run) DomUtils.appendChild(p, node);
    }
    run = [];
  };
  for (const child of [...root.children]) {
    if (isTag(child) && BLOCK_TAGS.has(child.name)) flush();
    else if (child.type === "text" || isTag(child)) run.push(child);
  }
  flush();
}

/** Replaces an element with its children. */
function unwrap(element: Element) {
  for (const child of [...element.children]) DomUtils.prepend(element, child);
  DomUtils.removeElement(element);
}

/** True when `block` holds nothing besides `keep` (and blank text, line breaks and captions). */
function holdsOnly(block: Element, keep: AnyNode) {
  return block.children.every(
    (child) =>
      child === keep ||
      isBlank(child) ||
      (isTag(child) && child.name === "figcaption"),
  );
}

function faqsFromJsonLd(json: string): Faq[] {
  try {
    const data: unknown = JSON.parse(json);
    const nodes: unknown[] = [];
    const visit = (value: unknown) => {
      if (Array.isArray(value)) value.forEach(visit);
      else if (value && typeof value === "object") {
        nodes.push(value);
        visit((value as { "@graph"?: unknown })["@graph"]);
      }
    };
    visit(data);
    return nodes
      .filter((node) => (node as { "@type"?: unknown })["@type"] === "FAQPage")
      .flatMap((node) => {
        const entities = (node as { mainEntity?: unknown }).mainEntity;
        return (Array.isArray(entities) ? entities : [entities]).map(
          (entity) => ({
            question: decodeHtml(
              String((entity as { name?: unknown })?.name ?? ""),
            ),
            answer: decodeHtml(
              String(
                (entity as { acceptedAnswer?: { text?: unknown } })
                  ?.acceptedAnswer?.text ?? "",
              ),
            ),
          }),
        );
      })
      .filter((faq) => faq.question && faq.answer);
  } catch {
    return [];
  }
}

const headingLevel = (el: Element) =>
  /^h([1-6])$/.exec(el.name) ? Number(el.name[1]) : null;

/** A paragraph that is a single bold line ending in "?", the other way posts write FAQ questions. */
/** Whitespace, a line break, or an element holding only those (Word's trailing <span> </span>). */
const isEmpty = (node: AnyNode) =>
  isBlank(node) ||
  (isTag(node) &&
    !nodeText(node) &&
    !DomUtils.findOne((el) => el.name === "img", node.children));

function isBoldQuestion(el: Element) {
  if (el.name !== "p") return false;
  const content = el.children.filter((child) => !isEmpty(child));
  return (
    content.length === 1 &&
    isTag(content[0]) &&
    ["strong", "b"].includes(content[0].name) &&
    /\?$/.test(nodeText(el))
  );
}

/** A paragraph that is a single bold line without end punctuation, written as a heading: "Conclusion". */
function isBoldTitle(el: Element) {
  if (el.name !== "p") return false;
  const content = el.children.filter((child) => !isEmpty(child));
  const text = nodeText(el);
  return (
    content.length === 1 &&
    isTag(content[0]) &&
    ["strong", "b"].includes(content[0].name) &&
    text.length > 0 &&
    text.length <= 80 &&
    !/[?.:!,;]$/.test(text)
  );
}

/** A paragraph whose only text is a link. */
function isLinkOnly(el: Element) {
  if (el.name !== "p") return false;
  const links = findAll(el, (child) => child.name === "a");
  if (!links.length) return false;
  const letters = (value: string) => value.replace(/[^\p{L}\p{N}]/gu, "");
  return letters(nodeText(el)) === letters(nodeText(links));
}

function answerText(block: Element): string | null {
  if (block.name === "p") return nodeText(block);
  if (block.name === "ul" || block.name === "ol") {
    return block.children
      .filter((child): child is Element => isTag(child) && child.name === "li")
      .map(
        (li, index) =>
          `${block.name === "ol" ? `${index + 1}.` : "•"} ${nodeText(li)}`,
      )
      .join("\n");
  }
  return null;
}

/**
 * Turns the article's "Frequently Asked Questions" section into FAQ items and
 * removes it from the text, so the website doesn't show the questions twice.
 * Leaves the section alone when its layout isn't one this understands.
 */
function extractFaqs(root: Element, warnings: string[]): Faq[] {
  const blocks = root.children.filter((child): child is Element =>
    isTag(child),
  );
  const start = blocks.findIndex(
    (block) =>
      headingLevel(block) !== null &&
      /^(faqs?\b|frequently asked questions|common questions)/i.test(
        nodeText(block),
      ),
  );
  if (start === -1) return [];

  const level = headingLevel(blocks[start])!;
  let end = blocks.length;
  for (let index = start + 1; index < blocks.length; index++) {
    const blockLevel = headingLevel(blocks[index]);
    if (blockLevel !== null && blockLevel <= level) {
      end = index;
      break;
    }
  }
  let section = blocks.slice(start + 1, end);
  // A bold line that isn't a question ("Conclusion") starts the next part of the article.
  const titleAt = section.findIndex(isBoldTitle);
  if (titleAt !== -1) section = section.slice(0, titleAt);
  // A call-to-action line closing the section ("→ Start your free trial") stays in the article.
  while (section.length && isLinkOnly(section[section.length - 1]))
    section = section.slice(0, -1);

  const faqs: Faq[] = [];
  const lostLinks: string[] = [];
  let current: { question: string; answers: string[] } | null = null;
  const leaveAsText = (reason: string) => {
    warnings.push(`FAQ section left in the article text: ${reason}.`);
    return [];
  };

  for (const block of section) {
    const blockLevel = headingLevel(block);
    if ((blockLevel !== null && blockLevel > level) || isBoldQuestion(block)) {
      if (current) {
        if (!current.answers.length)
          return leaveAsText(`"${current.question}" has no answer`);
        faqs.push({
          question: current.question,
          answer: current.answers.join("\n\n"),
        });
      }
      current = {
        question: nodeText(block).replace(/^(q\s*[:.]|\d+[.)])\s*/i, ""),
        answers: [],
      };
      continue;
    }
    const text = answerText(block);
    if (text === null) return leaveAsText(`it contains a <${block.name}>`);
    if (!current) {
      if (!text) continue;
      return leaveAsText("text before the first question");
    }
    if (text) current.answers.push(text.replace(/^a\s*[:.]\s*/i, ""));
    for (const link of findAll(
      block,
      (el) => el.name === "a" && Boolean(el.attribs.href),
    )) {
      lostLinks.push(link.attribs.href);
    }
  }
  if (current) {
    if (!current.answers.length)
      return leaveAsText(`"${current.question}" has no answer`);
    faqs.push({
      question: current.question,
      answer: current.answers.join("\n\n"),
    });
  }

  if (!faqs.length) return leaveAsText("no questions found under the heading");
  if (faqs.length > 50) return leaveAsText("more than 50 questions");
  const tooLong = faqs.find(
    (faq) => faq.question.length > 300 || faq.answer.length > 5000,
  );
  if (tooLong)
    return leaveAsText(
      `"${truncate(tooLong.question, 60)}" is too long for an FAQ item`,
    );

  for (const block of [blocks[start], ...section])
    DomUtils.removeElement(block);
  if (lostLinks.length) {
    warnings.push(
      `FAQ answers are plain text, so these links in them were dropped: ${lostLinks.join(", ")}`,
    );
  }
  return faqs;
}

function convertContent(post: WpPost): ConvertedContent {
  const warnings: string[] = [];
  const root = createElement(
    "root",
    {},
    parseDocument(post.content.rendered).children,
  );

  // Scripts: FAQ structured data is kept for a cross-check; everything else goes.
  const jsonLdFaqs: Faq[] = [];
  for (const el of findAll(root, (el) =>
    ["script", "style", "noscript", "iframe", "object", "embed"].includes(
      el.name,
    ),
  )) {
    if (el.name === "script" && /ld\+json/i.test(el.attribs.type ?? "")) {
      jsonLdFaqs.push(...faqsFromJsonLd(DomUtils.textContent(el)));
    } else if (el.name !== "script" && el.name !== "style") {
      warnings.push(
        `Removed an embedded <${el.name}> (${el.attribs.src ?? "no src"}).`,
      );
    }
    DomUtils.removeElement(el);
  }

  // Text pasted from Word arrives as <div><span>…</span></div>: those become paragraphs.
  for (const div of findAll(root, (el) => el.name === "div").reverse()) {
    if (
      div.children.some((child) => isTag(child) && BLOCK_TAGS.has(child.name))
    ) {
      unwrap(div);
    } else {
      div.name = "p";
      div.attribs = {};
    }
  }

  // Text left loose between blocks (from unwrapped <div>s) goes into paragraphs.
  wrapLooseText(root);

  // The page already has the article title as its H1.
  for (const h1 of findAll(root, (el) => el.name === "h1")) h1.name = "h2";

  // Paragraphs holding only images: the images stand on their own, in order.
  for (const p of findAll(root, (el) => el.name === "p")) {
    if (!nodeText(p) && DomUtils.findOne((el) => el.name === "img", p.children))
      unwrap(p);
  }

  // Images become the editor's <figure data-align><img></figure> blocks.
  const figures: Figure[] = [];
  for (const img of findAll(root, (el) => el.name === "img")) {
    const src = parseUrl(img.attribs.src)?.href;
    if (!src) {
      DomUtils.removeElement(img);
      warnings.push("Removed an image without a usable URL.");
      continue;
    }
    const entry = registerImage(src, post.slug, mediaForImg(img));

    let outer: Element = img;
    while (
      outer.parent &&
      isTag(outer.parent) &&
      IMAGE_WRAPPERS.has(outer.parent.name) &&
      holdsOnly(outer.parent, outer)
    ) {
      outer = outer.parent;
      const href = outer.name === "a" ? outer.attribs.href : undefined;
      const target = parseUrl(href);
      if (href && (!target || originalUploadUrl(target.href) !== entry.key)) {
        warnings.push(
          `An image linked to ${href}; images can't carry links, so it was dropped.`,
        );
      }
    }

    const block = outer.parent && isTag(outer.parent) ? outer.parent : null;
    const captionEl =
      block?.name === "figure"
        ? DomUtils.findOne((el) => el.name === "figcaption", block.children)
        : null;
    const caption = captionEl ? nodeText(captionEl) : "";

    const figureImg = createElement("img", {
      src: entry.key,
      alt: img.attribs.alt ?? "",
    });
    const figure = createElement("figure", { "data-align": "center" }, [
      figureImg,
      ...(caption
        ? [createElement("figcaption", {}, [new Text(caption)])]
        : []),
    ]);

    if (
      block &&
      block !== root &&
      ["p", "figure"].includes(block.name) &&
      holdsOnly(block, outer)
    ) {
      DomUtils.replaceElement(block, figure);
    } else if (block && block !== root && block.name === "p") {
      DomUtils.removeElement(outer);
      DomUtils.append(block, figure);
      warnings.push(
        "Moved an image that sat inside a sentence to below its paragraph.",
      );
    } else {
      DomUtils.replaceElement(outer, figure);
    }
    figures.push({
      img: figureImg,
      entry,
      alt: img.attribs.alt ?? "",
      width: img.attribs.width,
      height: img.attribs.height,
    });
  }

  // Empty paragraphs (Word's spacer <div> </div>, a removed script's wrapper).
  for (const p of findAll(root, (el) => el.name === "p").reverse()) {
    if (p.children.every(isBlank)) DomUtils.removeElement(p);
  }

  // An article that opens with a banner image: that banner becomes the featured image.
  let leading: Figure | null = null;
  const first = root.children.find((child) => !isBlank(child));
  if (first && isTag(first) && first.name === "figure") {
    leading = figures.find((figure) => figure.img.parent === first) ?? null;
    if (leading) {
      if (DomUtils.findOne((el) => el.name === "figcaption", first.children)) {
        warnings.push(
          "The opening banner's caption was dropped (it is now the featured image).",
        );
      }
      DomUtils.removeElement(first);
      figures.splice(figures.indexOf(leading), 1);
    }
  }

  const faqs = extractFaqs(root, warnings);
  if (jsonLdFaqs.length && faqs.length && jsonLdFaqs.length !== faqs.length) {
    warnings.push(
      `The FAQ structured data had ${jsonLdFaqs.length} questions; the visible section had ${faqs.length}, which were imported.`,
    );
  }
  if (jsonLdFaqs.length && !faqs.length) {
    warnings.push(
      "FAQ structured data was dropped because the visible FAQ stayed in the text.",
    );
  }

  // Links: articles on this blog become site-relative; links to image files follow the image.
  const imageLinks: ConvertedContent["imageLinks"] = [];
  for (const link of findAll(
    root,
    (el) => el.name === "a" && Boolean(el.attribs.href),
  )) {
    const url = parseUrl(link.attribs.href);
    if (!url || !sameSite(url)) continue;
    if (url.pathname.startsWith("/wp-content/uploads/")) {
      imageLinks.push({ link, entry: registerImage(url.href, post.slug) });
    } else if (url.pathname.startsWith("/blog/")) {
      link.attribs.href = `${url.pathname}${url.search}${url.hash}`;
    }
  }

  return { root, figures, imageLinks, leading, faqs, warnings };
}

// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------

type PostReport = {
  slug: string;
  title: string;
  oldUrl: string;
  newPath: string;
  wpStatus: string;
  result:
    | "created"
    | "updated"
    | "would create"
    | "would update"
    | "skipped"
    | "failed";
  reason?: string;
  images: number;
  faqs: number;
  warnings: string[];
};

const statusFor = (wpStatus: string): ContentStatus =>
  wpStatus === "publish"
    ? "PUBLISHED"
    : wpStatus === "future"
      ? "SCHEDULED"
      : "DRAFT";

const gmtDate = (value: string) => new Date(`${value.replace(/Z$/, "")}Z`);

/** Yoast SEO fields of a post, decoded, with the site name taken off titles. */
function yoastFields(post: WpPost) {
  const yoast = post.yoast_head_json ?? {};
  const siteName =
    /\s*[-|–—»:]\s*(second\s*line(\s*number)?|secondlinenumber|second phone number)\s*$/i;
  const canonical = parseUrl(yoast.canonical);
  return {
    yoast,
    seoTitle: decodeHtml(yoast.title ?? "").replace(siteName, ""),
    metaDescription: decodeHtml(yoast.description ?? ""),
    ogTitle: decodeHtml(yoast.og_title ?? "").replace(siteName, ""),
    ogDescription: decodeHtml(yoast.og_description ?? ""),
    canonical,
    /** True when the canonical is the article's own URL (or missing). */
    isOwnUrl:
      !canonical ||
      (sameSite(canonical) &&
        canonical.pathname.replace(/\/?$/, "/") === blogPath(post.slug)),
  };
}

/** WordPress excerpt as plain text, without the "[…]" WordPress adds. */
const plainExcerpt = (post: WpPost) =>
  decodeHtml(post.excerpt.rendered).replace(/\s*(\[(…|\.\.\.)\]|…)\s*$/, "");

type Prepared = {
  post: WpPost;
  converted: ConvertedContent;
  featured: { entry: ImageEntry; alt: string } | null;
  og: ImageEntry | null;
};

// ---------------------------------------------------------------------------
// JSON export (--export-json)
// ---------------------------------------------------------------------------

/** An image reference in the export file: original URL, readable alt, title. */
function imageRef(entry: ImageEntry, alt = "") {
  return {
    sourceUrl: entry.key,
    alt: readableAlt(alt || entry.wpAlt),
    title: entry.wpTitle && !isFileNameLike(entry.wpTitle) ? entry.wpTitle : "",
  };
}

/**
 * Writes the converted posts as a blog export file. Nothing is downloaded or
 * stored: images keep their WordPress URLs and are listed per post.
 */
async function writeExportFile(
  filePath: string,
  input: {
    prepared: Prepared[];
    categories: WpTerm[];
    tags: WpTerm[];
    usedUsers: WpUser[];
    avatars: Map<number, ImageEntry>;
  },
) {
  const { prepared, categories, tags, usedUsers, avatars } = input;
  const usedCategoryIds = new Set(
    prepared.flatMap(({ post }) => post.categories),
  );
  const categorySlugs = new Map<number, string>();
  const exportCategories: BlogExportInput["categories"] = [];
  for (const term of categories) {
    if (!usedCategoryIds.has(term.id) || term.slug === "uncategorized")
      continue;
    const slug =
      slugify(decodeURIComponent(term.slug)) || slugify(decodeHtml(term.name));
    categorySlugs.set(term.id, slug);
    exportCategories.push({
      slug,
      name: decodeHtml(term.name),
      description: decodeHtml(term.description),
      status: "ACTIVE",
    });
  }

  const authorSlugs = new Map<number, string>();
  const exportAuthors: BlogExportInput["authors"] = [];
  for (const user of usedUsers) {
    const mapped = authorMap.get(user.slug);
    // A mapped author already exists in the CMS: the post points at it by slug.
    authorSlugs.set(
      user.id,
      mapped ?? (slugify(user.slug) || slugify(user.name)),
    );
    if (mapped) continue;
    const avatar = avatars.get(user.id);
    exportAuthors.push({
      slug: authorSlugs.get(user.id)!,
      name: user.name,
      role: "",
      bio: decodeHtml(user.description),
      profileImage: avatar ? imageRef(avatar, user.name) : null,
      status: "ACTIVE",
    });
  }
  const tagNames = new Map(tags.map((tag) => [tag.id, decodeHtml(tag.name)]));

  const posts: BlogExportInput["posts"] = prepared.map(
    ({ post, converted, featured, og }) => {
      const title = decodeHtml(post.title.rendered);
      const warnings = [...converted.warnings];

      // Images stay on WordPress: point each <img> at the full-size original.
      for (const figure of converted.figures) {
        const alt = readableAlt(figure.alt || figure.entry.wpAlt);
        if (!alt)
          warnings.push(`An image has no alt text: ${figure.entry.key}`);
        figure.img.attribs = {
          src: figure.entry.key,
          alt,
          ...(figure.width && figure.height
            ? { width: figure.width, height: figure.height }
            : {}),
        };
      }
      // Cleaned by the editor's sanitizer: the file holds what the CMS stores.
      const content = sanitizeContentHtml(
        DomUtils.getInnerHTML(converted.root, { encodeEntities: "utf8" }),
      );

      const {
        yoast,
        seoTitle,
        metaDescription,
        ogTitle,
        ogDescription,
        canonical,
        isOwnUrl,
      } = yoastFields(post);
      const category = post.categories
        .map((id) => categorySlugs.get(id))
        .filter((slug): slug is string => Boolean(slug));
      if (category.length > 1)
        warnings.push(
          "The post had several categories; the CMS keeps one, the first.",
        );
      const published = gmtDate(post.date_gmt);
      const modified = gmtDate(post.modified_gmt);

      return {
        slug: post.slug,
        title,
        excerpt: plainExcerpt(post),
        content,
        status: statusFor(post.status),
        publishedAt: published.toISOString(),
        updatedAt: modified.toISOString(),
        category: category[0] ?? null,
        author: authorSlugs.get(post.author) ?? null,
        tags: post.tags
          .map((id) => tagNames.get(id))
          .filter((name): name is string => Boolean(name)),
        featuredImage: featured
          ? {
              ...imageRef(featured.entry, featured.alt),
              alt: readableAlt(featured.alt || featured.entry.wpAlt) || title,
            }
          : null,
        seo: {
          title: seoTitle && seoTitle !== title ? seoTitle : "",
          metaDescription,
          canonicalUrl: isOwnUrl || !canonical ? "" : canonical.href,
          focusKeyword: "",
          ogTitle: ogTitle && ogTitle !== (seoTitle || title) ? ogTitle : "",
          ogDescription:
            ogDescription && ogDescription !== metaDescription
              ? ogDescription
              : "",
          ogImage: og ? imageRef(og) : null,
          robotsIndex: yoast.robots?.index !== "noindex",
          robotsFollow: yoast.robots?.follow !== "nofollow",
        },
        faqs: converted.faqs,
        faqSchemaEnabled: true,
        cta: null,
        related: { mode: "AUTOMATIC", slugs: [] },
        images: converted.figures.map((figure) =>
          imageRef(figure.entry, figure.alt),
        ),
        legacy: { id: post.id, url: post.link, status: post.status },
        warnings,
      };
    },
  );

  const file: BlogExportInput = {
    format: BLOG_EXPORT_FORMAT,
    version: BLOG_EXPORT_VERSION,
    source,
    exportedAt: new Date().toISOString(),
    categories: exportCategories,
    authors: exportAuthors,
    posts,
    redirects: [],
  };

  // Never write a file the importer would refuse.
  const check = blogExportSchema.safeParse(file);
  if (!check.success) {
    throw new Error(
      `The export doesn't match the format:\n${check.error.issues
        .slice(0, 20)
        .map((issue) => `  ${issue.path.join(".")}: ${issue.message}`)
        .join("\n")}`,
    );
  }

  const target = path.resolve(filePath);
  await mkdir(path.dirname(target), { recursive: true });
  await writeFile(target, JSON.stringify(file, null, 2) + "\n");

  const faqs = posts.reduce(
    (total, post) => total + (post.faqs?.length ?? 0),
    0,
  );
  const inlineImages = posts.reduce(
    (total, post) => total + (post.images?.length ?? 0),
    0,
  );
  console.log("");
  for (const post of posts) {
    const count = post.warnings?.length ?? 0;
    console.log(
      `${count ? "!" : "✓"} ${post.slug}${count ? `  (${count} note${count === 1 ? "" : "s"})` : ""}`,
    );
  }
  console.log(
    `\nWrote ${posts.length} posts, ${exportCategories.length} categories, ${exportAuthors.length} authors, ${faqs} FAQs and ${inlineImages} image references to ${target}`,
  );
}

async function main() {
  const config = env();
  const outDir = path.resolve(args.out);
  console.log(
    exportJsonPath
      ? `Exporting ${source} to ${exportJsonPath} (nothing is written to the CMS).`
      : `${dryRun ? "Dry run: nothing will be written. " : ""}Importing from ${source} into ${config.STORAGE_DRIVER} storage.`,
  );

  // ---- Read WordPress --------------------------------------------------------
  const statuses = wpAuth ? "publish,future,draft,pending,private" : "publish";
  let posts = await wpAll<WpPost>("posts", {
    status: statuses,
    orderby: "date",
    order: "asc",
  });
  if (onlySlugs.size) {
    posts = posts.filter((post) => onlySlugs.has(post.slug));
    const missing = [...onlySlugs].filter(
      (slug) => !posts.some((post) => post.slug === slug),
    );
    if (missing.length)
      console.warn(`Not found in WordPress: ${missing.join(", ")}`);
  }
  console.log(
    `Found ${posts.length} posts${wpAuth ? " (all statuses)" : " (published)"}.`,
  );
  if (!posts.length) return;

  const mediaIds = posts.flatMap((post) => [
    post.featured_media,
    ...[...post.content.rendered.matchAll(/\bwp-image-(\d+)\b/g)].map((match) =>
      Number(match[1]),
    ),
  ]);
  for (const media of await wpByIds<WpMedia>("media", mediaIds))
    mediaById.set(media.id, media);

  const categories = await wpAll<WpTerm>("categories");
  const tagIds = posts.flatMap((post) => post.tags);
  const tags = tagIds.length ? await wpByIds<WpTerm>("tags", tagIds) : [];
  const users = new Map(
    (await wpAll<WpUser>("users")).map((user) => [user.id, user]),
  );
  for (const id of new Set(posts.map((post) => post.author))) {
    if (users.has(id)) continue;
    try {
      users.set(id, (await wpJson<WpUser>(`users/${id}`)).body);
    } catch {
      console.warn(
        `WordPress author ${id} isn't public; their posts get no author.`,
      );
    }
  }

  // ---- Convert content and pick images --------------------------------------
  /** Small card thumbnails replaced by the article's banner: their old URLs redirect to it. */
  const replacedThumbs: { media: WpMedia; entry: ImageEntry }[] = [];

  const prepared: Prepared[] = posts.map((post) => {
    const converted = convertContent(post);
    const wpFeatured = mediaById.get(post.featured_media);
    let featured: Prepared["featured"] = null;
    if (converted.leading) {
      featured = { entry: converted.leading.entry, alt: converted.leading.alt };
      if (
        wpFeatured &&
        originalUploadUrl(wpFeatured.source_url) !== converted.leading.entry.key
      ) {
        replacedThumbs.push({
          media: wpFeatured,
          entry: converted.leading.entry,
        });
      }
    } else if (wpFeatured) {
      featured = {
        entry: registerImage(wpFeatured.source_url, post.slug, wpFeatured),
        alt: wpFeatured.alt_text,
      };
    } else if (post.featured_media) {
      converted.warnings.push(
        `Featured image ${post.featured_media} isn't public in WordPress.`,
      );
    }

    // A share image of its own, when Yoast had one that is neither the banner nor the thumbnail.
    const ogUrl = parseUrl(post.yoast_head_json?.og_image?.[0]?.url)?.href;
    const known = [featured?.entry.key, wpFeatured?.source_url].map(
      (url) => url && originalUploadUrl(url),
    );
    const og =
      ogUrl && !known.includes(originalUploadUrl(ogUrl))
        ? registerImage(ogUrl, post.slug)
        : null;

    return { post, converted, featured, og };
  });

  const usedUsers = [...new Set(posts.map((post) => post.author))]
    .map((id) => users.get(id))
    .filter((user): user is WpUser => Boolean(user));
  const avatars = new Map<number, ImageEntry>();
  for (const user of usedUsers) {
    const urls = Object.entries(user.avatar_urls ?? {}).sort(
      ([a], [b]) => Number(b) - Number(a),
    );
    const avatar = parseUrl(urls[0]?.[1]);
    if (
      avatar &&
      sameSite(avatar) &&
      avatar.pathname.startsWith("/wp-content/uploads/")
    ) {
      avatars.set(user.id, registerImage(avatar.href, `author:${user.slug}`));
    }
  }

  if (exportJsonPath) {
    await writeExportFile(exportJsonPath, {
      prepared,
      categories,
      tags,
      usedUsers,
      avatars,
    });
    return;
  }

  // ---- Store images ----------------------------------------------------------
  const entries = [...images.values()];
  console.log(`Copying ${entries.length} images…`);
  let done = 0;
  await mapPool(entries, concurrency, async (entry) => {
    try {
      entry.stored = await storeImage(entry);
    } catch (error) {
      entry.error = errorMessage(error);
    }
    done++;
    if (done % 10 === 0 || done === entries.length)
      console.log(`  ${done}/${entries.length}`);
  });
  const failedImages = entries.filter((entry) => entry.error);
  for (const entry of failedImages)
    console.warn(`  ✗ ${entry.key}: ${entry.error}`);

  // ---- Categories, tags and authors -----------------------------------------
  const categoryIds = new Map<number, string>();
  const usedCategoryIds = new Set(posts.flatMap((post) => post.categories));
  for (const term of categories.filter((term) =>
    usedCategoryIds.has(term.id),
  )) {
    if (term.slug === "uncategorized") continue;
    const slug =
      slugify(decodeURIComponent(term.slug)) || slugify(decodeHtml(term.name));
    const existing = await db.category.findUnique({ where: { slug } });
    if (existing) {
      categoryIds.set(term.id, existing.id);
    } else if (dryRun) {
      categoryIds.set(term.id, `new:${slug}`);
    } else {
      const created = await db.category.create({
        data: {
          name: decodeHtml(term.name),
          slug,
          description: decodeHtml(term.description) || null,
        },
      });
      categoryIds.set(term.id, created.id);
    }
  }
  const tagNames = new Map(tags.map((tag) => [tag.id, decodeHtml(tag.name)]));

  const authorIds = new Map<number, string>();
  const authorNotes: string[] = [];
  for (const user of usedUsers) {
    const mapped = authorMap.get(user.slug);
    if (mapped) {
      const author = await db.author.findUnique({ where: { slug: mapped } });
      if (!author)
        throw new Error(
          `--author-map: no CMS author has the slug "${mapped}".`,
        );
      authorIds.set(user.id, author.id);
      authorNotes.push(`${user.slug} → existing author ${mapped}`);
      continue;
    }
    const slug = slugify(user.slug) || slugify(user.name);
    const bio = decodeHtml(user.description) || null;
    const avatar = avatars.get(user.id)?.stored?.url ?? null;
    if (/^[a-z0-9_]+$/.test(user.name) && user.name.includes("_")) {
      authorNotes.push(
        `"${user.name}" looks like a login name, not a byline. Re-run with --author-map ${user.slug}=<cms-author-slug>, or rename the author in the CMS.`,
      );
    }
    const existing = await db.author.findUnique({ where: { slug } });
    if (dryRun) {
      authorIds.set(user.id, existing?.id ?? `new:${slug}`);
      authorNotes.push(
        `${user.slug} → ${existing ? "existing" : "new"} author ${slug}`,
      );
      continue;
    }
    if (existing) {
      // Only fill gaps: never overwrite what editors wrote in the CMS.
      await db.author.update({
        where: { id: existing.id },
        data: {
          ...(!existing.bio && bio && { bio }),
          ...(!existing.profileImage && avatar && { profileImage: avatar }),
        },
      });
      authorIds.set(user.id, existing.id);
    } else {
      const created = await db.author.create({
        data: { name: user.name, slug, bio, profileImage: avatar },
      });
      authorIds.set(user.id, created.id);
    }
    authorNotes.push(
      `${user.slug} → ${existing ? "existing" : "new"} author ${slug}`,
    );
  }

  // ---- Articles --------------------------------------------------------------
  const setting = await db.setting.findUnique({
    where: { key: "blogDefaults" },
  });
  const defaults = blogDefaultsSchema.parse(setting?.value ?? {});
  const reports: PostReport[] = [];
  const liveSlugs: string[] = [];

  for (const { post, converted, featured, og } of prepared) {
    const title = decodeHtml(post.title.rendered);
    const warnings = [...converted.warnings];
    const report: PostReport = {
      slug: post.slug,
      title,
      oldUrl: post.link,
      newPath: blogPath(post.slug),
      wpStatus: post.status,
      result: "failed",
      images: converted.figures.length + (featured ? 1 : 0),
      faqs: converted.faqs.length,
      warnings,
    };
    reports.push(report);

    try {
      const existing = await db.blog.findUnique({
        where: { slug: post.slug },
        select: { id: true },
      });
      if (existing && !args.overwrite) {
        report.result = "skipped";
        report.reason = "Already in the CMS (use --overwrite to replace it).";
        continue;
      }

      // Put the stored image URLs into the article.
      for (const figure of converted.figures) {
        const { stored } = figure.entry;
        const alt = readableAlt(figure.alt || figure.entry.wpAlt);
        if (!alt)
          warnings.push(`An image has no alt text: ${figure.entry.key}`);
        figure.img.attribs = {
          src: stored?.url ?? figure.entry.key,
          alt,
          ...(stored?.width && stored.height
            ? { width: String(stored.width), height: String(stored.height) }
            : figure.width && figure.height
              ? { width: figure.width, height: figure.height }
              : {}),
        };
        if (!stored)
          warnings.push(
            `Image not copied, still on WordPress: ${figure.entry.key}`,
          );
      }
      for (const { link, entry } of converted.imageLinks) {
        if (entry.stored)
          link.attribs.href = absoluteMediaUrl(entry.stored.url)!;
        else
          warnings.push(
            `A link points at a WordPress file that wasn't copied: ${entry.key}`,
          );
      }
      const content = DomUtils.getInnerHTML(converted.root, {
        encodeEntities: "utf8",
      });
      const leftovers = content.match(
        /https?:\/\/(www\.)?[^"'\s]*\/wp-content\/[^"'\s]*/g,
      );
      if (leftovers)
        warnings.push(
          `Still links to WordPress files: ${[...new Set(leftovers)].join(", ")}`,
        );

      if (featured && !featured.entry.stored) {
        warnings.push(`Featured image not copied: ${featured.entry.key}`);
      }
      const featuredAlt =
        readableAlt(featured?.alt || featured?.entry.wpAlt || "") || title;

      const {
        yoast,
        seoTitle,
        metaDescription,
        ogTitle,
        ogDescription,
        canonical,
        isOwnUrl,
      } = yoastFields(post);
      if (seoTitle.length > 120)
        warnings.push(
          "The Yoast SEO title is over 120 characters, so the article title is used.",
        );
      if (metaDescription.length > 320)
        warnings.push(
          "The Yoast meta description was over 320 characters and was shortened.",
        );

      const category = post.categories
        .map((id) => categoryIds.get(id))
        .filter((id): id is string => Boolean(id));
      if (category.length > 1) {
        warnings.push(
          "The post had several categories; the CMS keeps one, the first.",
        );
      }

      const status = statusFor(post.status);
      if (post.status === "private" || post.status === "pending") {
        warnings.push(
          `Was "${post.status}" in WordPress; imported as a draft.`,
        );
      }
      const published = gmtDate(post.date_gmt);
      const modified = gmtDate(post.modified_gmt);

      const base = emptyFormValues(defaults);
      const values: BlogFormValues = {
        ...base,
        title,
        slug: post.slug,
        excerpt: truncate(plainExcerpt(post) || metaDescription, 500),
        content,
        featuredImage: featured?.entry.stored?.url ?? "",
        featuredImageAlt: truncate(featuredAlt, 300),
        featuredImageTitle: "",
        categoryId: category[0] ?? null,
        authorId: authorIds.get(post.author) ?? null,
        tags: post.tags
          .map((id) => tagNames.get(id))
          .filter((name): name is string => Boolean(name)),
        publishedAt: status === "DRAFT" ? null : published.toISOString(),
        seoTitle:
          seoTitle && seoTitle !== title && seoTitle.length <= 120
            ? seoTitle
            : "",
        metaDescription: truncate(metaDescription, 320),
        canonicalUrl: isOwnUrl || !canonical ? "" : canonical.href,
        focusKeyword: "",
        ogTitle:
          ogTitle && ogTitle !== (seoTitle || title)
            ? truncate(ogTitle, 120)
            : "",
        ogDescription:
          ogDescription && ogDescription !== metaDescription
            ? truncate(ogDescription, 320)
            : "",
        ogImage: og?.stored?.url ?? "",
        robotsIndex: yoast.robots?.index !== "noindex",
        robotsFollow: yoast.robots?.follow !== "nofollow",
        faqs: converted.faqs.map((faq, index) => ({
          id: `wp-${index + 1}`,
          ...faq,
        })),
      };

      const parsed = blogFormSchema.safeParse(values);
      if (!parsed.success) {
        report.reason = parsed.error.issues
          .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
          .join("; ");
        continue;
      }
      const clean = normalizeValues(parsed.data);
      if (isContentEmpty(clean.content))
        warnings.push("The article text is empty after cleaning.");

      if (dryRun) {
        report.result = existing ? "would update" : "would create";
        continue;
      }

      const data = {
        ...blogColumns(clean),
        status,
        publishedAt: status === "DRAFT" ? null : published,
        scheduledAt: status === "SCHEDULED" ? published : null,
        contentUpdatedAt:
          status === "PUBLISHED" &&
          modified.getTime() - published.getTime() > 60_000
            ? modified
            : null,
        autosave: Prisma.DbNull,
        autosavedAt: null,
      };

      report.result = await db.$transaction(async (tx) => {
        let id: string;
        if (existing) {
          await tx.blog.update({
            where: { id: existing.id },
            data: { ...data, version: { increment: 1 } },
          });
          id = existing.id;
        } else {
          const created = await tx.blog.create({
            data: {
              ...(data as Prisma.BlogUncheckedCreateInput),
              createdAt: published,
            },
            select: { id: true },
          });
          id = created.id;
        }
        await writeRelations(tx, id, clean);
        if (status !== "DRAFT") await releaseSlugPath(tx, clean.slug);
        await createRevision(
          tx,
          id,
          clean,
          existing ? "updated" : "published",
          null,
        );
        return existing ? "updated" : "created";
      });
      if (status !== "DRAFT") liveSlugs.push(post.slug);
    } catch (error) {
      report.result = "failed";
      report.reason = errorMessage(error);
    }
  }

  // ---- Outputs ---------------------------------------------------------------
  await mkdir(outDir, { recursive: true });
  const absolute = (url: string) => {
    const value = absoluteMediaUrl(url)!;
    return value.startsWith("/") ? `${config.CMS_PUBLIC_URL}${value}` : value;
  };

  const imageRedirects = new Map<string, string>();
  for (const entry of entries) {
    if (!entry.stored) continue;
    for (const variant of entry.variants)
      imageRedirects.set(variant, absolute(entry.stored.url));
  }
  for (const { media, entry } of replacedThumbs) {
    if (!entry.stored) continue;
    const variants = [
      media.source_url,
      ...Object.values(media.media_details?.sizes ?? {}).map(
        (size) => size.source_url,
      ),
    ];
    for (const variant of variants) {
      if (!imageRedirects.has(variant))
        imageRedirects.set(variant, absolute(entry.stored.url));
    }
  }

  const archiveRedirects: [string, string][] = [];
  for (const term of categories.filter((term) =>
    usedCategoryIds.has(term.id),
  )) {
    if (!term.link) continue;
    const slug = slugify(decodeURIComponent(term.slug));
    archiveRedirects.push([
      term.link,
      term.slug === "uncategorized"
        ? `${source}/blog/`
        : `${source}/blog/?category=${slug}`,
    ]);
  }
  for (const user of usedUsers)
    if (user.link) archiveRedirects.push([user.link, `${source}/blog/`]);
  archiveRedirects.push([`${source}/blog/feed/`, `${source}/blog/`]);

  // Cloudflare Bulk Redirects CSV: source URL, target URL, status code.
  await writeFile(
    path.join(outDir, "image-redirects.csv"),
    [...imageRedirects]
      .map(([from, to]) => csvLine([from, to, 301]))
      .join("\n") + "\n",
  );
  await writeFile(
    path.join(outDir, "archive-redirects.csv"),
    archiveRedirects.map(([from, to]) => csvLine([from, to, 301])).join("\n") +
      "\n",
  );
  await writeFile(
    path.join(outDir, "url-checklist.csv"),
    [
      csvLine([
        "slug",
        "old_url",
        "new_path",
        "result",
        "images",
        "faqs",
        "warnings",
        "title",
      ]),
      ...reports.map((report) =>
        csvLine([
          report.slug,
          report.oldUrl,
          report.newPath,
          report.result,
          report.images,
          report.faqs,
          report.warnings.length,
          report.title,
        ]),
      ),
    ].join("\n") + "\n",
  );

  const count = (result: PostReport["result"]) =>
    reports.filter((report) => report.result === result).length;
  const altFixed = entries.filter(
    (entry) => entry.wpAlt && readableAlt(entry.wpAlt) !== entry.wpAlt,
  ).length;
  const summary = {
    created: count("created"),
    updated: count("updated"),
    wouldCreate: count("would create"),
    wouldUpdate: count("would update"),
    skipped: count("skipped"),
    failed: count("failed"),
    images: entries.length,
    imagesReused: entries.filter((entry) => entry.stored?.reused).length,
    imagesFailed: failedImages.length,
    altTextsMadeReadable: altFixed,
    faqItems: reports.reduce((total, report) => total + report.faqs, 0),
    postsWithWarnings: reports.filter((report) => report.warnings.length)
      .length,
  };
  await writeFile(
    path.join(outDir, "report.json"),
    JSON.stringify(
      {
        source,
        dryRun,
        finishedAt: new Date().toISOString(),
        summary,
        authors: authorNotes,
        posts: reports,
        failedImages: failedImages.map((entry) => ({
          url: entry.key,
          error: entry.error,
          usedBy: [...entry.usedBy],
        })),
      },
      null,
      2,
    ),
  );

  // ---- Refresh the website ---------------------------------------------------
  if (
    !dryRun &&
    liveSlugs.length &&
    config.SITE_URL &&
    config.SITE_REVALIDATE_SECRET
  ) {
    const tags = [siteCacheTags.posts, ...liveSlugs.map(siteCacheTags.post)];
    for (let start = 0; start < tags.length; start += 40) {
      try {
        const response = await fetch(`${config.SITE_URL}/api/revalidate/`, {
          method: "POST",
          headers: {
            "content-type": "application/json",
            authorization: `Bearer ${config.SITE_REVALIDATE_SECRET}`,
          },
          body: JSON.stringify({ tags: tags.slice(start, start + 40) }),
          signal: AbortSignal.timeout(10_000),
        });
        if (!response.ok)
          console.warn(`Website cache refresh answered ${response.status}.`);
      } catch (error) {
        console.warn(`Website cache refresh failed: ${errorMessage(error)}`);
      }
    }
  }

  // ---- Summary ---------------------------------------------------------------
  console.log("");
  for (const report of reports) {
    const mark =
      report.result === "failed" ? "✗" : report.warnings.length ? "!" : "✓";
    console.log(
      `${mark} ${report.result.padEnd(12)} ${report.slug}${report.reason ? `  (${report.reason})` : ""}`,
    );
  }
  console.log("");
  for (const note of authorNotes) console.log(`Author: ${note}`);
  console.log(JSON.stringify(summary, null, 2));
  console.log(`\nReport and redirect lists: ${outDir}`);
  if (summary.failed) process.exitCode = 1;
}

main()
  .catch((error) => {
    console.error(errorMessage(error));
    process.exitCode = 1;
  })
  .finally(() => db.$disconnect());
