/**
 * Talks to the Laravel blog CMS (the backend replacing this admin panel)
 * through its API: every request in the Laravel team's Postman collection.
 * The client is src/lib/laravel-cms/client.ts.
 *
 *   npm run laravel -- login                       prints an admin token to export
 *   npm run laravel -- me
 *   npm run laravel -- blogs [--page 1] [--page-size 15] [--q text] [--status DRAFT]
 *   npm run laravel -- create-draft --title "…" [--slug s] [--excerpt …] [--content "<p>…</p>"] [--tags a,b]
 *   npm run laravel -- create-draft --values values.json [--intent draft]
 *   npm run laravel -- create-draft --title "…" --featured cover.webp [--image inline.webp …] [--alt "…"]
 *        one request with the images as Base64 (featuredImage, contentImages), as in the collection
 *   npm run laravel -- set-active <id> on|off      show or hide an article
 *   npm run laravel -- delete-blog <id> --yes      delete permanently (ADMIN)
 *   npm run laravel -- media [--q name] [--page 1]
 *   npm run laravel -- upload-media <image> [--alt "…"] [--title "…"]
 *   npm run laravel -- delete-media <id> --yes
 *   npm run laravel -- upload-images wordpress-import/blog-export.json [--dry-run] [--out file.json]
 *        uploads the file's images the CMS doesn't have to the media library and writes
 *        <name>.cms.json pointing at them (default); then import that file
 *   npm run laravel -- check-images wordpress-import/blog-export.json      are its images on the CMS server?
 *   npm run laravel -- import wordpress-import/blog-export.json            dry run (writes nothing)
 *   npm run laravel -- import wordpress-import/blog-export.json --write    uploads the images the CMS
 *        doesn't have to its media library (writes <name>.cms.json pointing at them), dry run, then
 *        import if nothing fails and no image is missing [--allow-missing-images] [--update]
 *        [--skip-image-upload]
 *        [--report wordpress-import/laravel-import-report.json]
 *   npm run laravel -- verify wordpress-import/blog-export.json            after an import: does the public
 *        API serve each published article with the file's category, author, date, images and FAQs?
 *   npm run laravel -- encrypt '{"message":"hi"}'
 *   npm run laravel -- decrypt <ciphertext>        or "-" to read it from stdin
 *   npm run laravel -- public posts [--page 2] [--category slug] | post <slug> | sitemap | preview <token> [--raw]
 *
 * Environment (.env):
 *   CMS_ENV                 prod (https://api.secondlinenumber.com/api) or dev (staging, the default)
 *   LARAVEL_API_URL         optional: overrides the API CMS_ENV picks
 * Each of the settings below may also be set per server with a _PROD or _DEV
 * suffix (LARAVEL_ADMIN_TOKEN_PROD, …), used when CMS_ENV picks that server.
 *   LARAVEL_ADMIN_TOKEN     token from `login`; or, to log in on each run:
 *   LARAVEL_ADMIN_EMAIL     (+ LARAVEL_ADMIN_PASSWORD, else prompted, hidden).
 *                           Login is limited to 5 tries per 15 minutes, so prefer the token.
 *   LARAVEL_BLOG_API_KEY    optional: decrypt public responses locally, as the
 *                           websites do with CMS_ENCRYPTION_KEY. Without it,
 *                           `public` decrypts through the admin endpoint.
 *
 * Passwords are never taken from the command line, where they would land in
 * shell history.
 */
import "dotenv/config";

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";

import {
  type BlogExport,
  blogExportSchema,
} from "../src/features/blog-import/format";
import { uploadExportImages } from "../src/features/blog-import/image-upload";
import type { ImportResult } from "../src/features/blog-import/types";
import {
  type BlogStatus,
  type BlogValues,
  createLaravelCms,
  LaravelCmsError,
  paths,
  type SaveIntent,
  toDataUrl,
} from "../src/lib/laravel-cms/client";
import {
  cmsSetting,
  cmsTarget,
  laravelApiUrl,
} from "../src/lib/laravel-cms/environment";
import { sniffImageType } from "../src/lib/media/sniff";

const usage = `Usage: npm run laravel -- <command>
  login | me | blogs | create-draft | set-active <id> on|off | delete-blog <id>
  media | upload-media <image> | delete-media <id> | upload-images <file>
  check-images <file> | import <file> | verify <file> | encrypt <text> | decrypt <ciphertext|->
  public posts|post <slug>|sitemap|preview <token>
See scripts/laravel-cms.ts for the options.`;

const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    email: { type: "string" },
    page: { type: "string" },
    "page-size": { type: "string" },
    q: { type: "string" },
    status: { type: "string" },
    category: { type: "string" },
    title: { type: "string" },
    slug: { type: "string" },
    excerpt: { type: "string" },
    content: { type: "string" },
    tags: { type: "string" },
    values: { type: "string" },
    intent: { type: "string", default: "draft" },
    update: { type: "boolean", default: false },
    write: { type: "boolean", default: false },
    "allow-missing-images": { type: "boolean", default: false },
    "skip-image-upload": { type: "boolean", default: false },
    report: { type: "string" },
    raw: { type: "boolean", default: false },
    featured: { type: "string" },
    image: { type: "string", multiple: true },
    alt: { type: "string" },
    out: { type: "string" },
    "dry-run": { type: "boolean", default: false },
    yes: { type: "boolean", default: false },
  },
});

async function prompt(question: string, { hidden = false } = {}) {
  const rl = createInterface({ input: stdin, output: stdout, terminal: true });
  if (hidden) {
    // Mask typed characters.
    const write = (rl as unknown as { _writeToOutput: (text: string) => void })
      ._writeToOutput;
    (
      rl as unknown as { _writeToOutput: (text: string) => void }
    )._writeToOutput = (text) => {
      write.call(
        rl,
        text.startsWith(question) ? text : "*".repeat(text.length ? 1 : 0),
      );
    };
  }
  const answer = await rl.question(question);
  rl.close();
  if (hidden) stdout.write("\n");
  return answer.trim();
}

async function readStdin() {
  let text = "";
  for await (const chunk of stdin) text += chunk;
  return text.trim();
}

const print = (value: unknown) => console.log(JSON.stringify(value, null, 2));

const target = cmsTarget();
const cms = createLaravelCms({
  apiUrl: laravelApiUrl(),
  token: cmsSetting("LARAVEL_ADMIN_TOKEN"),
});
/** e.g. LARAVEL_ADMIN_TOKEN_PROD: where this server's setting goes in .env. */
const settingName = (name: string) => `${name}_${target.toUpperCase()}`;

async function login({ announce = false } = {}) {
  const email =
    args.email ??
    cmsSetting("LARAVEL_ADMIN_EMAIL") ??
    (await prompt("Email: "));
  const password =
    cmsSetting("LARAVEL_ADMIN_PASSWORD") ??
    (await prompt("Password: ", { hidden: true }));
  const { user } = await cms.login(email, password);
  if (announce) {
    console.error(
      `Logged in to ${cms.apiUrl} as ${user.name} <${user.email}> (${user.role}).`,
    );
  }
  return user;
}

/** Uses LARAVEL_ADMIN_TOKEN, or logs in with the email and password. */
async function ensureAuth() {
  if (cms.token) return;
  if (!cmsSetting("LARAVEL_ADMIN_EMAIL") && !args.email) {
    throw new Error(
      `Set ${settingName("LARAVEL_ADMIN_TOKEN")} (from \`npm run laravel -- login\`) or ${settingName("LARAVEL_ADMIN_EMAIL")} in .env.`,
    );
  }
  await login({ announce: true });
}

const toInt = (value: string | undefined, name: string) => {
  if (value === undefined) return undefined;
  const number = Number(value);
  if (!Number.isSafeInteger(number) || number < 1)
    throw new Error(`--${name} must be a whole number.`);
  return number;
};

function printImport(result: ImportResult) {
  const { summary } = result;
  console.log(
    `${result.dryRun ? "Check (dry run)" : "Import"} from ${result.source ?? "unknown source"}: ` +
      `${summary.posts} posts → ${summary.create} create, ${summary.update} update, ` +
      `${summary.skip} skip, ${summary.fail} fail; ${summary.faqs} FAQs.`,
  );
  console.log(
    `Categories created ${summary.categoriesCreated}, authors ${summary.authorsCreated}, ` +
      `redirects ${summary.redirectsCreated}; images from library ${summary.imagesFromLibrary}, ` +
      `media created ${summary.mediaCreated}, to upload ${summary.imagesToUpload}, missing ${summary.imagesMissing}.`,
  );
  for (const post of result.posts) {
    if (post.action === "fail" || post.warnings.length) {
      console.log(
        `  ${post.action.padEnd(6)} ${post.slug}${post.reason ? `: ${post.reason}` : ""}`,
      );
      for (const warning of post.warnings) console.log(`         ! ${warning}`);
    }
  }
  for (const note of result.notes) console.log(`  note: ${note}`);
  for (const image of result.imagesMissing ?? []) {
    console.log(
      `  image missing on the CMS server: ${image.slug} › ${image.field}: ${image.url}`,
    );
  }
  for (const image of result.imagesToUpload ?? []) {
    console.log(
      `  image to upload: ${image.slug} › ${image.field}: ${image.sourceUrl}`,
    );
  }
}

/**
 * Checks, before an import, that every image the export file links to is
 * already served by the CMS at `/uploads/<storageKey>`. The Laravel import
 * never downloads images: a linked image whose file isn't on the server is
 * left off the article (docs/blog-export-format.md, "Images").
 */
async function runCheckImages(filePath: string | undefined) {
  if (!filePath)
    throw new Error("Usage: npm run laravel -- check-images <file.json>");
  const data = JSON.parse(
    await readFile(path.resolve(filePath), "utf8"),
  ) as unknown;
  const origin = new URL(cms.apiUrl).origin;

  const urls = new Map<string, string>(); // url → where it is used
  const walk = (value: unknown, where: string) => {
    if (Array.isArray(value)) {
      value.forEach((item, i) => walk(item, `${where}[${i}]`));
    } else if (value && typeof value === "object") {
      const record = value as Record<string, unknown>;
      if (typeof record.sourceUrl === "string" && "alt" in record) {
        const url = typeof record.url === "string" ? record.url : "";
        if (url) urls.set(url, urls.get(url) ?? where);
        else console.log(`  not linked (no url): ${where}`);
      }
      for (const [key, child] of Object.entries(record)) {
        if (key === "content" && typeof child === "string") {
          for (const match of child.matchAll(/<img[^>]*\ssrc="([^"]+)"/g)) {
            const src = match[1];
            if (src.startsWith("/uploads/") && !urls.has(src)) {
              urls.set(src, `${where}.content`);
            }
          }
        }
        walk(child, where ? `${where}.${key}` : key);
      }
    }
  };
  walk(data, "");

  let missing = 0;
  for (const [url, where] of urls) {
    const absolute = url.startsWith("/") ? `${origin}${url}` : url;
    const status = await fetch(absolute, {
      method: "HEAD",
      signal: AbortSignal.timeout(15_000),
    }).then(
      (response) => response.status,
      () => 0,
    );
    if (status === 200) continue;
    missing++;
    console.log(`  ${status || "error"}  ${absolute}  (${where})`);
  }
  console.log(
    missing
      ? `${missing} of ${urls.size} images are not on ${origin}. Copy admin-panel/storage/uploads/ to the CMS's public/uploads/ before importing.`
      : `All ${urls.size} images are served by ${origin}.`,
  );
  if (missing) process.exitCode = 1;
}

async function runImport(filePath: string | undefined) {
  if (!filePath)
    throw new Error(
      "Usage: npm run laravel -- import <file.json> [--write] [--update]",
    );
  await ensureAuth();
  const absolute = path.resolve(filePath);
  let bytes = await readFile(absolute);

  // Laravel never downloads images: put the ones the CMS doesn't have in its
  // media library first and import a copy of the file pointing at them.
  const libraryUrls = new Set<string>();
  if (!args["skip-image-upload"]) {
    const input = JSON.parse(
      bytes.toString("utf8").replace(/^\uFEFF/, ""),
    ) as unknown;
    const { data, report } = await uploadImagesOf(input, !args.write);
    console.log(
      `Images: ${report.images} in the file, ${report.onCms} already on the CMS, ${report.inLibrary} in the media library, ` +
        `${report.uploaded} ${args.write ? "uploaded" : "to upload on --write"}, ${report.failed.length} unavailable.`,
    );
    for (const image of report.failed)
      console.log(`  ✗ ${image.label}: ${image.error} (${image.source})`);
    if (args.write && report.changed.length) {
      for (const { url } of report.changed) libraryUrls.add(url);
      const copy = absolute.replace(/(\.cms)?\.json$/i, "") + ".cms.json";
      bytes = Buffer.from(`${JSON.stringify(data, null, 2)}\n`);
      await writeFile(copy, bytes);
      console.log(
        `Importing ${path.relative(process.cwd(), copy)}, the file pointing at the media library.`,
      );
    }
  }
  const file = new Blob([bytes], { type: "application/json" });
  const options = {
    filename: path.basename(absolute),
    existing: args.update ? ("update" as const) : ("skip" as const),
  };

  const check = await cms.importJson(file, { ...options, dryRun: true });
  printImport(check.result);
  const parsed = blogExportSchema.safeParse(
    JSON.parse(bytes.toString("utf8").replace(/^﻿/, "")),
  );
  if (parsed.success) warnTaxonomy(parsed.data, check.result);
  let final = check.result;

  // Images just put in the library may still be listed as missing by a CMS
  // that only looks in public/uploads/; they are set from `sourceUrl` anyway.
  const origin = new URL(cms.apiUrl).origin;
  const missing = check.result.imagesMissing.filter(
    (image) => !libraryUrls.has(new URL(image.url, origin).toString()),
  );

  if (args.write) {
    if (check.result.summary.fail > 0) {
      console.error(
        `Not importing: ${check.result.summary.fail} article(s) would fail. Fix the file first.`,
      );
      process.exitCode = 1;
    } else if (missing.length > 0 && !args["allow-missing-images"]) {
      console.error(
        `Not importing: ${missing.length} image(s) are not on the CMS and couldn't be uploaded, so those articles would have no images. ` +
          "Check the ✗ lines above, or pass --allow-missing-images to import without them.",
      );
      process.exitCode = 1;
    } else {
      const written = await cms.importJson(file, { ...options, dryRun: false });
      console.log(written.message ?? "Imported.");
      printImport(written.result);
      final = written.result;
    }
  } else {
    console.log("Nothing was written. Add --write to import.");
  }

  if (args.report) {
    await writeFile(
      path.resolve(args.report),
      `${JSON.stringify(final, null, 2)}\n`,
    );
    console.log(`Report written to ${args.report}`);
  }
  if (final.summary.fail > 0) process.exitCode = 1;
}

/**
 * Laravel's report counts the categories and authors it creates and adds a
 * note for each (docs/laravel-blog-api.md §8.7, rule 2). When the file lists
 * some and the report mentions none of them, the importer has probably
 * skipped that step and the articles will have no category or author.
 */
function warnTaxonomy(data: BlogExport, result: ImportResult) {
  const notes = result.notes.join("\n");
  const groups = [
    {
      kind: "categories",
      slugs: data.categories.map((category) => category.slug),
      created: result.summary.categoriesCreated,
    },
    {
      kind: "authors",
      slugs: data.authors.map((author) => author.slug),
      created: result.summary.authorsCreated,
    },
  ];
  for (const { kind, slugs, created } of groups) {
    if (!slugs.length || created > 0) continue;
    if (slugs.some((slug) => notes.includes(slug))) continue;
    console.warn(
      `  ! The file lists ${slugs.length} ${kind} (${slugs.join(", ")}), but the CMS reported none created and no note about them. ` +
        `If they aren't on the CMS already, its importer is ignoring them and the articles will have no ${kind === "categories" ? "category" : "author"}. ` +
        "After importing, check with `npm run laravel -- verify <file>`.",
    );
  }
}

type PublicPost = {
  slug: string;
  title: string;
  contentHtml?: string;
  publishedAt: string;
  featuredImage: { url: string; alt: string } | null;
  category: { slug: string; name: string } | null;
  author: {
    slug: string;
    name: string;
    role: string | null;
    image: string | null;
  } | null;
  faqs?: unknown[];
  seo?: { title?: string };
};

/** HTTP status of a HEAD request (0 when it fails). */
const headStatus = (url: string) =>
  fetch(url, { method: "HEAD", signal: AbortSignal.timeout(15_000) }).then(
    (response) => response.status,
    () => 0,
  );

/**
 * After an import, reads each published article in the file back from the
 * public API and lists every difference that matters on the website:
 * category, author, publishing date, featured image and its alt text, images
 * inside the article, FAQ count and SEO title.
 */
async function runVerify(filePath: string | undefined) {
  if (!filePath)
    throw new Error("Usage: npm run laravel -- verify <file.json>");
  const data = blogExportSchema.parse(
    JSON.parse(
      (await readFile(path.resolve(filePath), "utf8")).replace(/^﻿/, ""),
    ),
  );
  const key = cmsSetting("LARAVEL_BLOG_API_KEY");
  if (!key) await ensureAuth(); // to decrypt through the admin endpoint
  const origin = new URL(cms.apiUrl).origin;
  const categories = new Map(data.categories.map((c) => [c.slug, c]));
  const authors = new Map(data.authors.map((a) => [a.slug, a]));
  const imageStatus = new Map<string, number>();
  const reachable = async (url: string) => {
    const absolute = url.startsWith("/") ? `${origin}${url}` : url;
    if (!imageStatus.has(absolute))
      imageStatus.set(absolute, await headStatus(absolute));
    return imageStatus.get(absolute) === 200;
  };

  const now = Date.now();
  const live = data.posts.filter(
    (post) =>
      post.status === "PUBLISHED" &&
      (!post.publishedAt || Date.parse(post.publishedAt) <= now),
  );
  let failing = 0;
  for (const expected of live) {
    const problems: string[] = [];
    const response = await cms.publicGet(paths.post(expected.slug));
    if (response.status !== 200) {
      console.log(`✗ ${expected.slug}: not served (HTTP ${response.status})`);
      failing++;
      continue;
    }
    const { post } = (await cms.readPublic(response, key)) as {
      post: PublicPost;
    };

    if (expected.category !== (post.category?.slug ?? null)) {
      problems.push(
        `category is ${post.category ? `“${post.category.slug}”` : "empty"}, the file says “${expected.category}”`,
      );
    } else if (
      post.category &&
      categories.get(post.category.slug)?.name !== post.category.name
    ) {
      problems.push(
        `category name is “${post.category.name}”, the file says “${categories.get(post.category.slug)?.name}”`,
      );
    }

    if (expected.author !== (post.author?.slug ?? null)) {
      problems.push(
        `author is ${post.author ? `“${post.author.slug}”` : "empty"}, the file says “${expected.author}”`,
      );
    } else if (post.author) {
      const author = authors.get(post.author.slug);
      if (author && author.name !== post.author.name)
        problems.push(
          `author name is “${post.author.name}”, the file says “${author.name}”`,
        );
      if (author?.profileImage && !post.author.image)
        problems.push("author photo is empty");
      else if (post.author.image && !(await reachable(post.author.image)))
        problems.push(`author photo doesn't load: ${post.author.image}`);
    }

    if (expected.publishedAt) {
      const drift = Math.abs(
        Date.parse(post.publishedAt) - Date.parse(expected.publishedAt),
      );
      if (drift > 60_000)
        problems.push(
          `published ${post.publishedAt}, the file says ${expected.publishedAt}`,
        );
    }

    if (expected.featuredImage && !post.featuredImage) {
      problems.push("featured image is empty");
    } else if (post.featuredImage) {
      if (!(await reachable(post.featuredImage.url)))
        problems.push(`featured image doesn't load: ${post.featuredImage.url}`);
      const alt = expected.featuredImage?.alt ?? "";
      if (alt && post.featuredImage.alt !== alt)
        problems.push(
          `featured image alt is “${post.featuredImage.alt}”, the file says “${alt}”`,
        );
    }

    for (const match of (post.contentHtml ?? "").matchAll(
      /<img[^>]*\ssrc="([^"]+)"/g,
    )) {
      if (!(await reachable(match[1])))
        problems.push(`image in the article doesn't load: ${match[1]}`);
    }

    const faqs = post.faqs?.length ?? 0;
    if (faqs !== expected.faqs.length)
      problems.push(`${faqs} FAQs, the file has ${expected.faqs.length}`);
    if (expected.seo.title && post.seo?.title !== expected.seo.title)
      problems.push(
        `SEO title is “${post.seo?.title ?? ""}”, the file says “${expected.seo.title}”`,
      );

    if (problems.length) {
      failing++;
      console.log(`✗ ${expected.slug}`);
      for (const problem of problems) console.log(`    ${problem}`);
    } else {
      console.log(`✓ ${expected.slug}`);
    }
  }
  console.log(
    failing
      ? `${failing} of ${live.length} published articles differ from the file.`
      : `All ${live.length} published articles match the file.`,
  );
  if (failing) process.exitCode = 1;
}

/** An image file from disk, checked like the admin panel's uploader checks it. */
async function readImage(filePath: string) {
  const bytes = new Uint8Array(await readFile(path.resolve(filePath)));
  const mimeType = sniffImageType(bytes);
  if (!mimeType)
    throw new Error(`${filePath} isn't a JPG, PNG, WebP or AVIF image.`);
  return { bytes, mimeType, filename: path.basename(filePath) };
}

const maxUploadBytes =
  (Number(process.env.MEDIA_MAX_UPLOAD_MB) || 10) * 1024 * 1024;

/** Runs src/features/blog-import/image-upload.ts on parsed export data. */
function uploadImagesOf(input: unknown, dryRun: boolean) {
  const storage = path.resolve("storage", "uploads");
  return uploadExportImages(input, {
    cms,
    dryRun,
    maxBytes: maxUploadBytes,
    readStored: (key) => {
      const file = path.resolve(storage, key);
      if (!file.startsWith(storage + path.sep)) return Promise.resolve(null);
      return readFile(file).then(
        (buffer) => new Uint8Array(buffer),
        () => null,
      );
    },
    onProgress: (done, total) => {
      if (done === total || done % 10 === 0)
        console.error(`  ${done}/${total} images checked`);
    },
  });
}

/**
 * Uploads the images of an export file that the CMS doesn't have yet to its
 * media library and writes a copy of the file that points at them
 * (src/features/blog-import/image-upload.ts). Files come from
 * storage/uploads/ when it has them, otherwise from `sourceUrl`.
 */
async function runUploadImages(filePath: string | undefined) {
  if (!filePath)
    throw new Error(
      "Usage: npm run laravel -- upload-images <file.json> [--dry-run] [--out out.json]",
    );
  await ensureAuth();
  const input = JSON.parse(
    (await readFile(path.resolve(filePath), "utf8")).replace(/^\uFEFF/, ""),
  ) as unknown;
  const dryRun = args["dry-run"];
  const { data, report } = await uploadImagesOf(input, dryRun);

  console.log(
    `${report.images} images: ${report.onCms} already on the CMS, ${report.inLibrary} in the media library, ` +
      `${report.uploaded} ${dryRun ? "to upload" : "uploaded"}, ${report.failed.length} unavailable.`,
  );
  for (const image of report.failed)
    console.log(`  ✗ ${image.label}: ${image.error} (${image.source})`);
  if (dryRun) {
    console.log("Nothing was uploaded (dry run).");
    if (report.failed.length) process.exitCode = 1;
    return;
  }

  const checked = blogExportSchema.safeParse(data);
  if (!checked.success) {
    for (const issue of checked.error.issues.slice(0, 10))
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    throw new Error(
      "The updated file doesn't match the export format; not written.",
    );
  }
  const output =
    args.out ?? filePath.replace(/(\.cms)?\.json$/i, "") + ".cms.json";
  await writeFile(path.resolve(output), `${JSON.stringify(data, null, 2)}\n`);
  console.log(
    `Wrote ${output} (${report.changed.length} image references now point at the media library). Import that file:`,
  );
  console.log(`  npm run laravel -- import ${output}`);
  if (report.failed.length) process.exitCode = 1;
}

async function runPublic(kind: string | undefined, value: string | undefined) {
  let endpoint: string;
  switch (kind) {
    case "posts": {
      const query = new URLSearchParams({
        page: String(toInt(args.page, "page") ?? 1),
        perPage: "12",
      });
      if (args.category) query.set("category", args.category);
      endpoint = `${paths.posts}?${query}`;
      break;
    }
    case "post":
      if (!value)
        throw new Error("Usage: npm run laravel -- public post <slug>");
      endpoint = paths.post(value);
      break;
    case "sitemap":
      endpoint = paths.sitemap;
      break;
    case "preview":
      if (!value)
        throw new Error("Usage: npm run laravel -- public preview <token>");
      endpoint = paths.preview(value);
      break;
    default:
      throw new Error(
        "Usage: npm run laravel -- public posts|post <slug>|sitemap|preview <token>",
      );
  }

  const response = await cms.publicGet(endpoint);
  console.error(
    `GET ${endpoint.split("?")[0]} → ${response.status}, encryption: ${response.encryption ?? "none"}`,
  );
  if (args.raw) {
    console.log(response.raw);
    return;
  }
  const key = cmsSetting("LARAVEL_BLOG_API_KEY");
  if (response.encryption && !key) await ensureAuth();
  print(await cms.readPublic(response, key));
}

async function main() {
  const [command, value, extra] = positionals;
  // Always say which server, so nothing is written to production by mistake.
  if (command)
    console.error(
      `CMS: ${target === "prod" ? "PRODUCTION" : "dev (staging)"} · ${cms.apiUrl}`,
    );
  switch (command) {
    case "login": {
      await login({ announce: true });
      console.error("Add this to admin-panel/.env (keep it private):");
      console.log(`${settingName("LARAVEL_ADMIN_TOKEN")}=${cms.token}`);
      return;
    }
    case "me":
      await ensureAuth();
      return print(await cms.me());
    case "blogs": {
      await ensureAuth();
      const page = await cms.listBlogs({
        page: toInt(args.page, "page"),
        pageSize: toInt(args["page-size"], "page-size"),
        q: args.q,
        status: args.status?.toUpperCase() as BlogStatus | undefined,
      });
      console.log(
        `Page ${page.current_page} of ${page.last_page} (${page.total} articles)`,
      );
      for (const post of page.data) {
        console.log(
          `  ${post.status.padEnd(9)} ${post.slug}  “${post.title}”  ${post.id}`,
        );
      }
      return;
    }
    case "create-draft": {
      await ensureAuth();
      let values: BlogValues;
      if (args.values) {
        values = JSON.parse(
          await readFile(path.resolve(args.values), "utf8"),
        ) as BlogValues;
      } else {
        if (!args.title)
          throw new Error("Pass --title, or --values <file.json>.");
        values = {
          title: args.title,
          ...(args.slug ? { slug: args.slug } : {}),
          ...(args.excerpt ? { excerpt: args.excerpt } : {}),
          ...(args.content ? { content: args.content } : {}),
          ...(args.tags
            ? {
                tags: args.tags
                  .split(",")
                  .map((tag) => tag.trim())
                  .filter(Boolean),
              }
            : {}),
        };
      }
      // Images in the same request, as Base64 (the collection's
      // "Create draft with Base64 images — one API call").
      if (args.featured) {
        const image = await readImage(args.featured);
        values.featuredImage = {
          filename: image.filename,
          data: toDataUrl(image.bytes, image.mimeType),
          alt: args.alt ?? values.title,
        };
      }
      if (args.image?.length) {
        const images = await Promise.all(args.image.map(readImage));
        values.contentImages = images.map((image, i) => ({
          clientId: `inline-${i + 1}`,
          filename: image.filename,
          data: toDataUrl(image.bytes, image.mimeType),
          alt: args.alt ?? "",
        }));
        // Each image goes where content has <img data-blog-image-id="inline-N">; append any not placed.
        let content = typeof values.content === "string" ? values.content : "";
        images.forEach((_, i) => {
          const id = `inline-${i + 1}`;
          if (!content.includes(`data-blog-image-id="${id}"`))
            content += `<img data-blog-image-id="${id}">`;
        });
        values.content = content;
      }
      const { message, post } = await cms.createBlog(
        values,
        args.intent as SaveIntent,
      );
      console.log(
        `${message ?? "Saved"}: ${post.status} ${post.slug} (${post.id}, version ${post.version})`,
      );
      return;
    }
    case "set-active": {
      if (!value || !["on", "off"].includes(extra ?? ""))
        throw new Error("Usage: npm run laravel -- set-active <id> on|off");
      await ensureAuth();
      const { message, post } = await cms.setBlogActive(value, extra === "on");
      console.log(
        `${message ?? "Saved"}: ${post?.slug ?? value} is_active=${String(post?.is_active ?? extra === "on")}`,
      );
      return;
    }
    case "delete-blog": {
      if (!value)
        throw new Error("Usage: npm run laravel -- delete-blog <id> --yes");
      if (!args.yes)
        throw new Error(
          "Deleting can't be undone. Add --yes to delete " + value + ".",
        );
      await ensureAuth();
      console.log((await cms.deleteBlog(value)).message ?? "Deleted.");
      return;
    }
    case "media": {
      await ensureAuth();
      const page = await cms.listMedia({
        q: args.q,
        page: toInt(args.page, "page"),
      });
      console.log(
        `${page.total} images (page ${toInt(args.page, "page") ?? 1} of ${page.lastPage})`,
      );
      for (const item of page.items) {
        console.log(
          `  ${item.id}  ${item.url}  ${item.width ?? "?"}×${item.height ?? "?"}  ${item.size} B  “${item.alt}”`,
        );
      }
      return;
    }
    case "upload-media": {
      if (!value)
        throw new Error(
          "Usage: npm run laravel -- upload-media <image> [--alt …] [--title …]",
        );
      await ensureAuth();
      const image = await readImage(value);
      const media = await cms.uploadMedia({
        ...image,
        alt: args.alt,
        title: args.title,
      });
      print(media);
      return;
    }
    case "delete-media": {
      if (!value)
        throw new Error("Usage: npm run laravel -- delete-media <id> --yes");
      if (!args.yes)
        throw new Error(
          "Deleting can't be undone. Add --yes to delete " + value + ".",
        );
      await ensureAuth();
      console.log((await cms.deleteMedia(value)).message ?? "Deleted.");
      return;
    }
    case "upload-images":
      return runUploadImages(value);
    case "check-images":
      return runCheckImages(value);
    case "import":
      return runImport(value);
    case "verify":
      return runVerify(value);
    case "encrypt":
      await ensureAuth();
      return console.log(
        await cms.encrypt(
          value === "-" || value === undefined ? await readStdin() : value,
        ),
      );
    case "decrypt":
      await ensureAuth();
      return console.log(
        await cms.decrypt(
          value === "-" || value === undefined ? await readStdin() : value,
        ),
      );
    case "public":
      return runPublic(value, extra);
    default:
      console.error(usage);
      process.exitCode = 1;
  }
}

main().catch((error: unknown) => {
  if (error instanceof LaravelCmsError) {
    console.error(`${error.message} (HTTP ${error.status})`);
    for (const [field, message] of Object.entries(error.fieldErrors)) {
      console.error(`  ${field}: ${message}`);
    }
  } else {
    console.error(error instanceof Error ? error.message : error);
  }
  process.exitCode = 1;
});
