/**
 * WordPress → OrthoGlobe blog migrator.
 *
 * Walks the public Yoast sitemap at orthoglobe.org, scrapes every post page,
 * downloads the featured image (plus inline images), rewrites image URLs to
 * point at the new /uploads/legacy/YYYY-MM/ paths, and inserts rows into
 * blog_posts + media_assets. Idempotent — re-running skips posts whose slug
 * is already present.
 *
 * Usage:
 *   pnpm tsx scripts/wp-migrate.ts            # full run, writes to DB
 *   pnpm tsx scripts/wp-migrate.ts --dry-run  # no DB writes, no downloads
 *   pnpm tsx scripts/wp-migrate.ts --limit=5  # process first N posts only
 *   pnpm tsx scripts/wp-migrate.ts --start=radial-head-replacement
 *   pnpm tsx scripts/wp-migrate.ts --force    # overwrite existing rows
 */
import { config } from "dotenv";
import path from "node:path";
import { promises as fs } from "node:fs";
import { createHash } from "node:crypto";
import { load } from "cheerio";

config({ path: path.resolve(process.cwd(), ".env.local") });
config({ path: path.resolve(process.cwd(), ".env") });

// --------- args ---------
const args = process.argv.slice(2);
const DRY_RUN = args.includes("--dry-run");
const FORCE = args.includes("--force");
const LIMIT = Number(args.find((a) => a.startsWith("--limit="))?.split("=")[1] ?? "0");
const START = args.find((a) => a.startsWith("--start="))?.split("=")[1] ?? "";

const WP_ORIGIN = "https://orthoglobe.org";
const SITEMAP_INDEX = `${WP_ORIGIN}/wp-sitemap.xml`;
const USER_AGENT =
  "OrthoGlobeMigrator/1.0 (+https://orthoglobe.org migration - contact info@orthoglobe.org)";
const FETCH_DELAY_MS = 250; // be a polite scraper
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "legacy");

type ParsedPost = {
  slug: string;
  url: string;
  title: string;
  excerpt: string;
  bodyHtml: string;
  authorName: string;
  publishedAt: Date | null;
  updatedAt: Date | null;
  coverImageUrl: string | null;
  tags: string[];
};

type ImageRecord = {
  sourceUrl: string;
  localPath: string; // absolute disk path
  publicPath: string; // /uploads/legacy/YYYY/MM/file.ext
  sha256: string;
  mime: string;
  sizeBytes: number;
};

type MigrationReport = {
  startedAt: string;
  finishedAt: string;
  dryRun: boolean;
  totalSitemapUrls: number;
  processed: number;
  inserted: number;
  skipped: number;
  updated: number;
  failed: Array<{ slug: string; url: string; error: string }>;
  imagesDownloaded: number;
  imagesSkipped: number;
};

async function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

async function fetchText(url: string, retries = 3): Promise<string> {
  for (let attempt = 1; attempt <= retries; attempt += 1) {
    try {
      const res = await fetch(url, {
        headers: { "user-agent": USER_AGENT, accept: "text/html,application/xml" },
        redirect: "follow",
      });
      if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
      return await res.text();
    } catch (err) {
      if (attempt === retries) throw err;
      await sleep(500 * attempt);
    }
  }
  throw new Error("unreachable");
}

async function fetchBinary(
  url: string,
  opts: { retries?: number; timeoutMs?: number } = {},
): Promise<{ buffer: Buffer; mime: string }> {
  const retries = opts.retries ?? 1;
  const timeoutMs = opts.timeoutMs ?? 8_000;
  for (let attempt = 1; attempt <= retries; attempt += 1) {
    const controller = new AbortController();
    const t = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const res = await fetch(url, {
        headers: { "user-agent": USER_AGENT },
        redirect: "follow",
        signal: controller.signal,
      });
      clearTimeout(t);
      if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
      const mime = res.headers.get("content-type")?.split(";")[0] ?? "application/octet-stream";
      return { buffer: Buffer.from(await res.arrayBuffer()), mime };
    } catch (err) {
      clearTimeout(t);
      if (attempt === retries) throw err;
      await sleep(400 * attempt);
    }
  }
  throw new Error("unreachable");
}

function slugFromUrl(url: string): string {
  const u = new URL(url);
  const seg = u.pathname.replace(/\/+$/, "").split("/").pop() || "";
  return seg.toLowerCase();
}

async function listSitemapUrls(): Promise<Array<{ url: string; lastmod: string }>> {
  const indexXml = await fetchText(SITEMAP_INDEX);
  const childSitemaps = Array.from(
    indexXml.matchAll(/<loc>([^<]+)<\/loc>/g),
  ).map((m) => m[1]);
  const postSitemap = childSitemaps.find((u) => u.includes("post-sitemap"));
  if (!postSitemap) throw new Error("Could not find post-sitemap in index");
  const xml = await fetchText(postSitemap);
  const entries = Array.from(
    xml.matchAll(/<url>\s*<loc>([^<]+)<\/loc>\s*(?:<lastmod>([^<]+)<\/lastmod>)?/g),
  ).map((m) => ({ url: m[1], lastmod: m[2] || "" }));
  return entries;
}

function guessMimeFromUrl(url: string): string {
  const ext = path.extname(new URL(url).pathname).toLowerCase();
  if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
  if (ext === ".png") return "image/png";
  if (ext === ".webp") return "image/webp";
  if (ext === ".gif") return "image/gif";
  if (ext === ".svg") return "image/svg+xml";
  return "application/octet-stream";
}

function extFromMime(mime: string): string {
  if (mime === "image/jpeg") return "jpg";
  if (mime === "image/png") return "png";
  if (mime === "image/webp") return "webp";
  if (mime === "image/gif") return "gif";
  if (mime === "image/svg+xml") return "svg";
  return "bin";
}

/** Strip WP resize suffixes so /image-768x623.png resolves to /image.png */
function normaliseImageUrl(raw: string): string {
  try {
    const u = new URL(raw, WP_ORIGIN);
    u.pathname = u.pathname.replace(/-\d{2,4}x\d{2,4}(\.[a-z]{3,4})$/i, "$1");
    return u.toString();
  } catch {
    return raw;
  }
}

/** Choose the best image src — prefer data-src for lazy-loaded, fall back to src. */
function bestImgSrc(el: unknown): string | null {
  const attribs = (el as unknown as { attribs?: Record<string, string> }).attribs ?? {};
  const dataSrc = attribs["data-src"];
  const src = attribs["src"];
  // WP's lazy loader puts a base64 SVG placeholder in src, real URL in data-src.
  if (dataSrc && !dataSrc.startsWith("data:")) return dataSrc;
  if (src && !src.startsWith("data:")) return src;
  return null;
}

function parsePost(url: string, html: string): ParsedPost {
  const $ = load(html);

  const ogTitle =
    $('meta[property="og:title"]').attr("content") ||
    $("h1.entry-title").first().text().trim() ||
    "Untitled";
  const ogDescription = $('meta[property="og:description"]').attr("content") ?? "";
  const published = $('meta[property="article:published_time"]').attr("content") ?? null;
  const modified = $('meta[property="article:modified_time"]').attr("content") ?? null;
  const coverImageUrl = $('meta[property="og:image"]').attr("content") ?? null;

  const authorName = $("span.author-name").first().text().trim() || "OrthoGlobe";

  const tags: string[] = [];
  $("a.ast-button.ast-badge-tax").each((_, el) => {
    const label = $(el).text().trim().replace(/\.+$/, "").replace(/\s+/g, " ");
    if (label && !tags.includes(label)) tags.push(label);
  });

  const $content = $("div.entry-content").first().clone();
  // Strip WP cruft: related posts, share bars, nav, scripts, styles.
  $content
    .find(
      "script, style, noscript, iframe, form, .ast-related-posts-wrapper, .sharedaddy, .jp-relatedposts, .code-block, .wp-block-buttons, .post-navigation, .comments-area, .entry-footer",
    )
    .remove();
  // Normalise images: swap lazy data-src → src and strip dimension suffixes.
  $content.find("img").each((_, el) => {
    const real = bestImgSrc(el);
    const $el = $(el);
    if (real) {
      $el.attr("src", normaliseImageUrl(real));
      $el.removeAttr("data-src");
      $el.removeAttr("data-lazyloaded");
      $el.removeAttr("data-srcset");
      $el.removeAttr("data-sizes");
      $el.removeAttr("srcset");
      $el.removeAttr("sizes");
      // Drop width/height so responsive layout can take over.
      $el.removeAttr("width");
      $el.removeAttr("height");
    } else {
      $el.remove();
    }
  });
  // Strip inline styles and script attributes.
  $content.find("*").each((_, el) => {
    const $el = $(el);
    $el.removeAttr("style");
    for (const attr of Object.keys((el as unknown as { attribs?: Record<string, string> }).attribs ?? {})) {
      if (attr.startsWith("on")) $el.removeAttr(attr);
    }
  });

  return {
    slug: slugFromUrl(url),
    url,
    title: ogTitle,
    excerpt: ogDescription.replace(/&#8217;/g, "'").replace(/&nbsp;/g, " ").trim(),
    bodyHtml: $content.html()?.trim() ?? "",
    authorName,
    publishedAt: published ? new Date(published) : null,
    updatedAt: modified ? new Date(modified) : null,
    coverImageUrl: coverImageUrl ? normaliseImageUrl(coverImageUrl) : null,
    tags,
  };
}

const imageCache = new Map<string, ImageRecord>();
const imageFailCache = new Set<string>();

async function downloadImage(
  sourceUrl: string,
  yyyyMm: string,
): Promise<ImageRecord | null> {
  if (imageCache.has(sourceUrl)) return imageCache.get(sourceUrl)!;
  const normalised = normaliseImageUrl(sourceUrl);
  if (imageFailCache.has(normalised)) return null;
  if (imageCache.has(normalised)) return imageCache.get(normalised)!;

  let buffer: Buffer;
  let mime: string;
  try {
    const result = await fetchBinary(normalised, { retries: 1, timeoutMs: 8_000 });
    buffer = result.buffer;
    mime = result.mime;
  } catch (err) {
    imageFailCache.add(normalised);
    console.error(`   image failed: ${normalised} - ${(err as Error).message}`);
    return null;
  }
  if (!mime.startsWith("image/")) mime = guessMimeFromUrl(normalised);

  const sha256 = createHash("sha256").update(buffer).digest("hex");
  const shortHash = sha256.slice(0, 16);
  const ext = extFromMime(mime);
  const filename = `${shortHash}.${ext}`;
  const dir = path.join(UPLOADS_DIR, yyyyMm);
  await fs.mkdir(dir, { recursive: true });
  const abs = path.join(dir, filename);
  try {
    await fs.access(abs);
  } catch {
    await fs.writeFile(abs, buffer);
  }

  const record: ImageRecord = {
    sourceUrl: normalised,
    localPath: abs,
    publicPath: `/uploads/legacy/${yyyyMm}/${filename}`,
    sha256,
    mime,
    sizeBytes: buffer.byteLength,
  };
  imageCache.set(sourceUrl, record);
  return record;
}

function yyyyMm(d: Date | null): string {
  const base = d ?? new Date();
  return `${base.getUTCFullYear()}-${String(base.getUTCMonth() + 1).padStart(2, "0")}`;
}

async function rewriteBodyImages(
  bodyHtml: string,
  bucket: string,
  downloadedPaths: Set<string>,
): Promise<string> {
  if (!bodyHtml) return bodyHtml;
  const $ = load(`<div id="root">${bodyHtml}</div>`);
  const imgs = $("img").toArray();
  for (const el of imgs) {
    const src = $(el).attr("src");
    if (!src) continue;
    if (src.startsWith("/uploads/")) continue;
    if (!/^https?:\/\//.test(src)) continue;
    if (DRY_RUN) continue;
    // Download every remote image so the post is self-contained and CSP-clean.
    const rec = await downloadImage(src, bucket);
    if (rec) {
      $(el).attr("src", rec.publicPath);
      downloadedPaths.add(rec.publicPath);
    } else {
      // Failed download — drop the <img> so CSP doesn't spam console errors.
      $(el).remove();
    }
  }
  return $("#root").html() ?? bodyHtml;
}

async function main() {
  const report: MigrationReport = {
    startedAt: new Date().toISOString(),
    finishedAt: "",
    dryRun: DRY_RUN,
    totalSitemapUrls: 0,
    processed: 0,
    inserted: 0,
    skipped: 0,
    updated: 0,
    failed: [],
    imagesDownloaded: 0,
    imagesSkipped: 0,
  };

  console.log(
    `\nWP migrator\n  dry-run:   ${DRY_RUN}\n  force:     ${FORCE}\n  limit:     ${LIMIT || "(all)"}\n  start:     ${START || "(beginning)"}\n`,
  );

  console.log("Phase 1 — enumerate sitemap…");
  const allUrls = await listSitemapUrls();
  report.totalSitemapUrls = allUrls.length;
  console.log(`   found ${allUrls.length} post URLs`);

  // Lazy-import DB + schema so dry-run can work without env set.
  let db: typeof import("@/lib/db/client")["db"] | null = null;
  let schema: typeof import("@/lib/db/schema") | null = null;
  if (!DRY_RUN) {
    if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL not set");
    const postgres = (await import("postgres")).default;
    const { drizzle } = await import("drizzle-orm/postgres-js");
    schema = (await import("../src/lib/db/schema")) as typeof import(
      "@/lib/db/schema"
    );
    const sql = postgres(process.env.DATABASE_URL!, { max: 4 });
    db = drizzle(sql, { schema }) as typeof import("@/lib/db/client")["db"];
  }

  let queue = allUrls;
  if (START) {
    const idx = queue.findIndex((e) => e.url.includes(START));
    if (idx >= 0) queue = queue.slice(idx);
  }
  if (LIMIT > 0) queue = queue.slice(0, LIMIT);

  // Load existing slugs so we can skip unless --force.
  const existing = new Set<string>();
  if (db && schema) {
    const rows = await db
      .select({ slug: schema.blogPosts.slug })
      .from(schema.blogPosts);
    for (const r of rows) existing.add(r.slug);
    console.log(`   ${existing.size} slugs already in DB`);
  }

  console.log(`\nPhase 2 — fetch + parse + insert (${queue.length} posts)…`);
  for (let i = 0; i < queue.length; i += 1) {
    const entry = queue[i];
    const slug = slugFromUrl(entry.url);
    const progress = `[${String(i + 1).padStart(3, " ")}/${queue.length}]`;

    try {
      if (existing.has(slug) && !FORCE) {
        console.log(`${progress} skip (exists): ${slug}`);
        report.skipped += 1;
        report.processed += 1;
        continue;
      }

      const html = await fetchText(entry.url);
      const parsed = parsePost(entry.url, html);
      const bucket = yyyyMm(parsed.publishedAt);

      let coverImagePath: string | null = null;
      let coverImageAlt: string | null = null;
      const inlinePaths = new Set<string>();
      if (!DRY_RUN && parsed.coverImageUrl) {
        const rec = await downloadImage(parsed.coverImageUrl, bucket);
        if (rec) {
          coverImagePath = rec.publicPath;
          coverImageAlt = parsed.title;
          report.imagesDownloaded += 1;
        }
      }
      const rewrittenBody = await rewriteBodyImages(
        parsed.bodyHtml,
        bucket,
        inlinePaths,
      );
      report.imagesDownloaded += inlinePaths.size;

      if (DRY_RUN || !db || !schema) {
        console.log(
          `${progress} parsed: ${slug} — ${parsed.title.slice(0, 60)} · ${parsed.tags.length} tags · cover=${
            parsed.coverImageUrl ? "yes" : "no"
          } · body=${parsed.bodyHtml.length}ch`,
        );
        report.processed += 1;
        continue;
      }

      if (existing.has(slug) && FORCE) {
        const { eq } = await import("drizzle-orm");
        await db
          .update(schema.blogPosts)
          .set({
            title: parsed.title,
            excerpt: parsed.excerpt,
            content: rewrittenBody,
            coverImage: coverImagePath,
            coverImageAlt,
            authorName: parsed.authorName,
            tags: parsed.tags,
            status: "published",
            publishedAt: parsed.publishedAt ?? new Date(),
            updatedAt: parsed.updatedAt ?? new Date(),
          })
          .where(eq(schema.blogPosts.slug, slug));
        report.updated += 1;
      } else {
        await db.insert(schema.blogPosts).values({
          slug,
          title: parsed.title,
          excerpt: parsed.excerpt,
          content: rewrittenBody,
          coverImage: coverImagePath,
          coverImageAlt,
          authorName: parsed.authorName,
          tags: parsed.tags,
          status: "published",
          publishedAt: parsed.publishedAt ?? new Date(),
          updatedAt: parsed.updatedAt ?? new Date(),
        });
        existing.add(slug);
        report.inserted += 1;
      }

      // Register every downloaded image in media_assets for the library.
      const allImages: ImageRecord[] = [];
      if (coverImagePath) {
        const rec = imageCache.get(parsed.coverImageUrl!);
        if (rec) allImages.push(rec);
      }
      for (const p of inlinePaths) {
        const rec = [...imageCache.values()].find((r) => r.publicPath === p);
        if (rec) allImages.push(rec);
      }
      for (const rec of allImages) {
        await db
          .insert(schema.mediaAssets)
          .values({
            path: rec.publicPath,
            originalName: path.basename(new URL(rec.sourceUrl).pathname),
            mimeType: rec.mime,
            fileSize: rec.sizeBytes,
            sha256: rec.sha256,
          })
          .onConflictDoNothing({ target: schema.mediaAssets.path });
      }

      console.log(`${progress} inserted: ${slug}`);
      report.processed += 1;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      console.error(`${progress} FAILED: ${slug} — ${message}`);
      report.failed.push({ slug, url: entry.url, error: message });
      report.processed += 1;
    }

    await sleep(FETCH_DELAY_MS);
  }

  report.finishedAt = new Date().toISOString();
  const reportPath = path.join(
    process.cwd(),
    `wp-migration-report-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`,
  );
  await fs.writeFile(reportPath, JSON.stringify(report, null, 2));

  console.log(`\nDone. Report: ${reportPath}`);
  console.log(
    `   processed ${report.processed} · inserted ${report.inserted} · updated ${report.updated} · skipped ${report.skipped} · failed ${report.failed.length}`,
  );
  console.log(
    `   images downloaded: ${report.imagesDownloaded} (cached ${imageCache.size})`,
  );
}

main()
  .then(() => process.exit(0))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });
