#!/usr/bin/env node

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { createRequire } from "node:module";
import { execFile, execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);
const CONFIG_DIR = path.join(os.homedir(), ".config", "codex-wechat-publisher");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const KEYCHAIN_SERVICE = "codex-wechat-publisher";
const WX_API = "https://api.weixin.qq.com";

function parseArgs(argv) {
  const [command = "help", ...rest] = argv;
  const options = { _: [] };
  for (let i = 0; i < rest.length; i += 1) {
    const item = rest[i];
    if (!item.startsWith("--")) {
      options._.push(item);
      continue;
    }
    const key = item.slice(2);
    const next = rest[i + 1];
    if (!next || next.startsWith("--")) options[key] = true;
    else {
      options[key] = next;
      i += 1;
    }
  }
  return { command, options };
}

function help() {
  console.log(`公众号草稿箱发布器

用法：
  push_wechat_draft.mjs setup
  push_wechat_draft.mjs doctor
  push_wechat_draft.mjs dry-run <article.html>
  push_wechat_draft.mjs push <article.html> [--title 标题] [--digest 摘要]

说明：
  setup     打开本机安全配置页，AppSecret 写入 macOS 钥匙串
  doctor    检查 access_token、IP 白名单和草稿箱接口权限
  dry-run   渲染并检查文章，不上传任何内容
  push      上传正文图片、封面并新建公众号草稿（不会群发）
`);
}

function htmlEscape(value) {
  return String(value ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

async function saveConfig(appid) {
  await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
  await fs.writeFile(CONFIG_FILE, `${JSON.stringify({ appid, updated_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
  await fs.chmod(CONFIG_FILE, 0o600);
}

function saveSecret(appid, secret) {
  execFileSync("security", ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", appid, "-w", secret], {
    stdio: ["ignore", "ignore", "pipe"],
  });
}

async function loadCredentials() {
  let config;
  try {
    config = JSON.parse(await fs.readFile(CONFIG_FILE, "utf8"));
  } catch {
    throw new Error("尚未配置公众号凭据，请先运行 setup。"
    );
  }
  if (!config.appid) throw new Error("本机配置缺少 AppID，请重新运行 setup。");
  let secret;
  try {
    secret = execFileSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", config.appid, "-w"], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"],
    }).trim();
  } catch {
    throw new Error("macOS 钥匙串中未找到 AppSecret，请重新运行 setup。");
  }
  return { appid: config.appid, secret };
}

function setupPage(message = "") {
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>微信公众号草稿箱接入</title><style>
  *{box-sizing:border-box}body{margin:0;background:#f5f7fb;color:#1f2937;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}.card{max-width:620px;margin:7vh auto;background:#fff;border-radius:18px;padding:34px;box-shadow:0 18px 50px rgba(31,41,55,.12)}h1{font-size:25px;margin:0 0 12px}.tip{background:#f0fdf4;border:1px solid #bbf7d0;padding:14px;border-radius:10px;line-height:1.7;margin:18px 0}.warn{color:#b45309;background:#fffbeb;border-color:#fde68a}.err{color:#b91c1c;background:#fef2f2;border-color:#fecaca}label{display:block;font-weight:700;margin:18px 0 7px}input{width:100%;padding:13px 14px;border:1px solid #d1d5db;border-radius:9px;font-size:16px}button{width:100%;margin-top:24px;padding:14px;border:0;border-radius:10px;background:#07c160;color:#fff;font-size:16px;font-weight:700;cursor:pointer}.muted{font-size:13px;color:#6b7280;line-height:1.7;margin-top:18px}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}</style></head><body><main class="card"><h1>微信公众号草稿箱接入</h1><p>凭据只保存到这台 Mac：AppID 写入权限为 600 的本机配置，AppSecret 写入 macOS 钥匙串。</p>${message ? `<div class="tip err">${htmlEscape(message)}</div>` : `<div class="tip warn">请在微信公众平台的“设置与开发 → 基本配置”中获取 AppID 和 AppSecret。不要在聊天中发送 AppSecret。</div>`}<form method="post" action="/save"><label>公众号 AppID</label><input name="appid" autocomplete="off" required placeholder="wx..."><label>公众号 AppSecret</label><input type="password" name="secret" autocomplete="new-password" required placeholder="仅保存到 macOS 钥匙串"><button type="submit">安全保存并继续检查</button></form><p class="muted">保存后只会做接口连通性检查。推送草稿时仍需明确执行 <code>push</code>，不会自动群发。</p></main></body></html>`;
}

async function runSetup() {
  await new Promise((resolve, reject) => {
    const server = http.createServer(async (req, res) => {
      try {
        if (req.method === "GET" && req.url === "/") {
          res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
          res.end(setupPage());
          return;
        }
        if (req.method === "POST" && req.url === "/save") {
          let body = "";
          for await (const chunk of req) body += chunk;
          const form = new URLSearchParams(body);
          const appid = (form.get("appid") || "").trim();
          const secret = (form.get("secret") || "").trim();
          if (!/^wx[a-zA-Z0-9]{8,}$/.test(appid) || secret.length < 16) {
            res.writeHead(400, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
            res.end(setupPage("AppID 或 AppSecret 格式不正确，请回到公众平台重新复制。"));
            return;
          }
          await saveConfig(appid);
          saveSecret(appid, secret);
          res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
          res.end(`<!doctype html><meta charset="utf-8"><style>body{font-family:-apple-system,"PingFang SC";max-width:640px;margin:10vh auto;padding:30px;line-height:1.8}h1{color:#07c160}</style><h1>保存成功</h1><p>AppSecret 已进入 macOS 钥匙串。你可以关闭此页并回到 Codex，我会继续检查接口权限和 IP 白名单。</p>`);
          setTimeout(() => server.close(() => resolve()), 500);
          return;
        }
        res.writeHead(404).end();
      } catch (error) {
        reject(error);
      }
    });
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      const url = `http://127.0.0.1:${address.port}/`;
      console.log(`安全配置页：${url}`);
      execFile("open", [url], (error) => {
        if (error) console.error(`无法自动打开浏览器，请手动访问：${url}`);
      });
    });
    server.on("error", reject);
  });
  console.log("配置已安全保存。");
}

async function wxJson(url, init = {}, context = "微信接口") {
  const response = await fetch(url, init);
  const text = await response.text();
  let payload;
  try { payload = JSON.parse(text); } catch { throw new Error(`${context}返回了非 JSON：HTTP ${response.status}`); }
  if (!response.ok || payload.errcode) {
    const err = new Error(`${context}失败：${payload.errcode ?? response.status} ${payload.errmsg ?? ""}`.trim());
    err.errcode = payload.errcode;
    err.errmsg = payload.errmsg;
    throw err;
  }
  return payload;
}

async function getAccessToken() {
  const { appid, secret } = await loadCredentials();
  const payload = await wxJson(`${WX_API}/cgi-bin/stable_token`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credential", appid, secret, force_refresh: false }),
  }, "获取 access_token");
  return { token: payload.access_token, appid, expiresIn: payload.expires_in };
}

function friendlyError(error) {
  const message = String(error?.errmsg || error?.message || error);
  const code = Number(error?.errcode);
  if (code === 40164 || /invalid ip/i.test(message)) return `当前出口 IP 尚未加入公众号白名单。微信返回：${message}`;
  if (code === 40013) return "AppID 无效，请确认复制的是公众号 AppID，不是小程序或开放平台 AppID。";
  if (code === 40125) return "AppSecret 无效或已重置，请重新运行 setup。";
  if (code === 48001 || code === 48002) return "当前公众号没有草稿箱接口权限。请在公众平台“接口权限”中确认草稿管理能力和账号认证状态。";
  return message;
}

async function doctor() {
  try {
    const { token, appid, expiresIn } = await getAccessToken();
    const count = await wxJson(`${WX_API}/cgi-bin/draft/count?access_token=${encodeURIComponent(token)}`, {}, "检查草稿箱权限");
    console.log(JSON.stringify({ ok: true, appid, token_expires_in: expiresIn, draft_count: count.total_count ?? count.count ?? null }, null, 2));
  } catch (error) {
    console.error(`检查未通过：${friendlyError(error)}`);
    process.exitCode = 2;
  }
}

function getPlaywright() {
  try { return require("playwright"); }
  catch { throw new Error("缺少 Playwright。请按公众号技能给出的 NODE_PATH 和 Node.js 路径运行本脚本。"); }
}

async function renderArticle(htmlPath) {
  const absolute = path.resolve(htmlPath);
  await fs.access(absolute);
  const { chromium } = getPlaywright();
  const browser = await chromium.launch({ headless: true });
  try {
    const page = await browser.newPage({ viewport: { width: 720, height: 1000 } });
    await page.goto(pathToFileURL(absolute).href, { waitUntil: "load" });
    await page.waitForFunction(() => {
      const root = document.getElementById("article-content");
      if (!root) return false;
      return [...root.querySelectorAll("img")].every((img) => img.src && (img.src.startsWith("data:image/") || img.src.startsWith("http")));
    }, { timeout: 30000 });
    return await page.evaluate(() => {
      const source = document.getElementById("article-content");
      if (!source) throw new Error("HTML 中找不到 #article-content");
      const title = source.querySelector("h1")?.textContent?.trim() || document.title || "未命名文章";
      const authorLine = source.querySelector(".author-info")?.textContent?.trim() || "詹老师";
      const author = authorLine.split(/[·|｜]/)[0].trim().slice(0, 16);
      let rich = typeof window.buildWechatArticleHtml === "function" ? window.buildWechatArticleHtml() : source.outerHTML;
      const holder = document.createElement("div");
      holder.innerHTML = rich;
      holder.querySelector("h1")?.remove();
      const firstAuthor = [...holder.querySelectorAll("p")].find((p) => p.textContent?.trim() === authorLine);
      firstAuthor?.remove();
      const images = [];
      for (const [index, img] of [...holder.querySelectorAll("img")].entries()) {
        const src = img.getAttribute("src") || "";
        if (!src) throw new Error(`第 ${index + 1} 张图片尚未完成渲染`);
        images.push({ src, alt: img.getAttribute("alt") || `文章图片${index + 1}` });
        img.setAttribute("src", `__WX_IMAGE_${index}__`);
        img.removeAttribute("id");
        img.removeAttribute("class");
      }
      const paragraphs = [...source.querySelectorAll("p")]
        .map((p) => p.textContent?.trim() || "")
        .filter((text) => text && text !== authorLine);
      const digest = paragraphs.join(" ").replace(/\s+/g, " ").slice(0, 110);
      return { title, author, authorLine, digest, content: holder.innerHTML, images };
    });
  } finally {
    await browser.close();
  }
}

async function imageBytes(src, articlePath) {
  if (src.startsWith("data:image/")) {
    const match = src.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/s);
    if (!match) throw new Error("图片 data URL 格式不受支持");
    return { bytes: Buffer.from(match[2], "base64"), mime: match[1] };
  }
  if (src.startsWith("file://")) {
    const url = new URL(src);
    return { bytes: await fs.readFile(url), mime: mimeFromName(url.pathname) };
  }
  if (/^https?:\/\//.test(src)) {
    const response = await fetch(src);
    if (!response.ok) throw new Error(`下载正文图片失败：HTTP ${response.status}`);
    return { bytes: Buffer.from(await response.arrayBuffer()), mime: response.headers.get("content-type")?.split(";")[0] || "image/jpeg" };
  }
  const local = path.resolve(path.dirname(articlePath), src);
  return { bytes: await fs.readFile(local), mime: mimeFromName(local) };
}

function mimeFromName(name) {
  const ext = path.extname(name).toLowerCase();
  if (ext === ".png") return "image/png";
  if (ext === ".gif") return "image/gif";
  if (ext === ".webp") return "image/webp";
  return "image/jpeg";
}

async function optimizeImage(image, maxBytes = 950 * 1024) {
  if (image.bytes.length <= maxBytes && ["image/png", "image/jpeg", "image/jpg", "image/gif"].includes(image.mime)) return image;
  let sharp;
  try { sharp = require("sharp"); }
  catch { throw new Error(`图片大小为 ${(image.bytes.length / 1024 / 1024).toFixed(2)}MB，超过正文上传限制，且本机缺少 sharp 无法自动压缩。`); }
  let quality = 88;
  let output;
  do {
    output = await sharp(image.bytes).flatten({ background: "#ffffff" }).resize({ width: 1280, withoutEnlargement: true }).jpeg({ quality, mozjpeg: true }).toBuffer();
    quality -= 8;
  } while (output.length > maxBytes && quality >= 48);
  if (output.length > maxBytes) throw new Error("正文图片自动压缩后仍超过微信限制，请降低 Canvas 尺寸后重试。");
  return { bytes: output, mime: "image/jpeg" };
}

async function uploadBodyImage(token, image, index) {
  const optimized = await optimizeImage(image);
  const ext = optimized.mime === "image/png" ? "png" : optimized.mime === "image/gif" ? "gif" : "jpg";
  const form = new FormData();
  form.append("media", new Blob([optimized.bytes], { type: optimized.mime }), `article-${index + 1}.${ext}`);
  const payload = await wxJson(`${WX_API}/cgi-bin/media/uploadimg?access_token=${encodeURIComponent(token)}`, { method: "POST", body: form }, `上传正文第 ${index + 1} 张图片`);
  if (!payload.url) throw new Error(`上传正文第 ${index + 1} 张图片后未返回 URL`);
  return payload.url;
}

async function uploadCover(token, image) {
  const optimized = await optimizeImage(image, 1900 * 1024);
  const ext = optimized.mime === "image/png" ? "png" : "jpg";
  const form = new FormData();
  form.append("media", new Blob([optimized.bytes], { type: optimized.mime }), `cover.${ext}`);
  const payload = await wxJson(`${WX_API}/cgi-bin/material/add_material?access_token=${encodeURIComponent(token)}&type=image`, { method: "POST", body: form }, "上传封面素材");
  if (!payload.media_id) throw new Error("上传封面后未返回 media_id");
  return payload.media_id;
}

async function dryRun(file, options = {}) {
  if (!file) throw new Error("请提供公众号 HTML 文件路径。");
  const rendered = await renderArticle(file);
  const images = [];
  for (const image of rendered.images) images.push(await imageBytes(image.src, path.resolve(file)));
  const uploadReady = [];
  for (const image of images) uploadReady.push(await optimizeImage(image));
  const report = {
    ok: true,
    title: options.title || rendered.title,
    author: options.author || rendered.author,
    digest_chars: (options.digest || rendered.digest).length,
    content_chars: rendered.content.length,
    images: images.map((image, index) => ({
      index: index + 1,
      source_mime: image.mime,
      source_size_kb: Math.round(image.bytes.length / 1024),
      upload_mime: uploadReady[index].mime,
      upload_size_kb: Math.round(uploadReady[index].bytes.length / 1024),
      optimized: uploadReady[index].bytes.length !== image.bytes.length || uploadReady[index].mime !== image.mime,
    })),
  };
  console.log(JSON.stringify(report, null, 2));
  return { rendered, images };
}

async function pushDraft(file, options = {}) {
  const { rendered, images } = await dryRun(file, options);
  if (!images.length) throw new Error("文章没有可用图片，无法生成封面。请先完成 Canvas PNG 渲染验收。");
  const { token } = await getAccessToken();
  const uploadedUrls = [];
  for (let i = 0; i < images.length; i += 1) {
    process.stderr.write(`上传正文图片 ${i + 1}/${images.length}...\n`);
    uploadedUrls.push(await uploadBodyImage(token, images[i], i));
  }
  const thumbMediaId = await uploadCover(token, images[0]);
  let content = rendered.content;
  uploadedUrls.forEach((url, index) => { content = content.replaceAll(`__WX_IMAGE_${index}__`, url); });
  const article = {
    title: String(options.title || rendered.title).trim(),
    author: String(options.author || rendered.author || "詹老师").trim(),
    digest: String(options.digest || rendered.digest).trim().slice(0, 120),
    content,
    content_source_url: String(options.source || ""),
    thumb_media_id: thumbMediaId,
    need_open_comment: Number(options.comments ?? 0),
    only_fans_can_comment: Number(options["fans-only"] ?? 0),
  };
  const payload = await wxJson(`${WX_API}/cgi-bin/draft/add?access_token=${encodeURIComponent(token)}`, {
    method: "POST",
    headers: { "content-type": "application/json; charset=utf-8" },
    body: JSON.stringify({ articles: [article] }),
  }, "新建公众号草稿");
  console.log(JSON.stringify({ ok: true, media_id: payload.media_id, title: article.title, images_uploaded: images.length, action: "已进入公众号草稿箱，未群发" }, null, 2));
}

const { command, options } = parseArgs(process.argv.slice(2));
try {
  if (["help", "--help", "-h"].includes(command)) help();
  else if (command === "setup") await runSetup();
  else if (command === "doctor") await doctor();
  else if (command === "dry-run") await dryRun(options._[0], options);
  else if (command === "push") await pushDraft(options._[0], options);
  else throw new Error(`未知命令：${command}`);
} catch (error) {
  console.error(`失败：${friendlyError(error)}`);
  process.exitCode = 1;
}
