#!/usr/bin/env node
"use strict";

/**
 * mnote-web 生产构建与启动入口。
 *
 * 规则：
 * - 只按已提交的 git HEAD 判断 release 是否已存在，未提交工作区改动不参与判定。
 * - 新 release 从 0.0.1 开始递增，0.0.9 后是 0.1.0。
 * - 每个 release 保留独立源码快照和二进制，不覆盖旧 release。
 * - 默认启动端口是 3003，避免占用开发端口 3000。
 */

const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn, spawnSync, execFileSync } = require("node:child_process");

const ROOT = path.resolve(__dirname, "..");
const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web");
const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web");
const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod");
const RUN_STATE_PATH = path.join(RUN_DIR, "process.json");
const DEFAULT_PORT = 3003;
const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const ENV_ALL_PATH = path.join(ROOT, ".env.all");
const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2";
const SUPPLEMENTAL_RUNTIME_ASSETS = [
  {
    source: "node_modules/jszip/dist/jszip.min.js",
    target: "node_modules/jszip/dist/jszip.min.js",
  },
  {
    source: "node_modules/docx-preview/dist/docx-preview.min.js",
    target: "node_modules/docx-preview/dist/docx-preview.min.js",
  },
  {
    source: "node_modules/@e965/xlsx/dist/xlsx.full.min.js",
    target: "node_modules/@e965/xlsx/dist/xlsx.full.min.js",
  },
  {
    source: "node_modules/pptx-preview/dist/pptx-preview.umd.js",
    target: "node_modules/pptx-preview/dist/pptx-preview.umd.js",
  },
  {
    source: "node_modules/pdfjs-dist/build/pdf.mjs",
    target: "node_modules/pdfjs-dist/build/pdf.mjs",
  },
  {
    source: "node_modules/pdfjs-dist/build/pdf.worker.mjs",
    target: "node_modules/pdfjs-dist/build/pdf.worker.mjs",
  },
  {
    source: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js",
    target: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js",
  },
];

const STARTUP_ASSET_PROBES = [
  "/api/office-preview/vendor/docx-preview.min.js",
  "/api/office-preview/vendor/xlsx.full.min.js",
  "/api/office-preview/vendor/pptx-preview.umd.js",
  "/api/pdfjs/pdf.mjs",
  "/api/pdfjs/pdf.worker.mjs",
  "/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js",
];

function loadEnvFile(filePath) {
  if (!fs.existsSync(filePath)) return {};
  const content = fs.readFileSync(filePath, "utf8");
  return content
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => line && !line.startsWith("#"))
    .reduce((acc, line) => {
      const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
      const idx = normalized.indexOf("=");
      if (idx === -1) return acc;
      const key = normalized.slice(0, idx).trim();
      let value = normalized.slice(idx + 1).trim();
      if (!key) return acc;
      if (
        (value.startsWith('"') && value.endsWith('"'))
        || (value.startsWith("'") && value.endsWith("'"))
      ) {
        value = value.slice(1, -1);
      }
      acc[key] = value;
      return acc;
    }, {});
}

function loadProdEnv() {
  const envFromAll = loadEnvFile(ENV_ALL_PATH);
  if (Object.keys(envFromAll).length > 0) {
    Object.assign(process.env, envFromAll);
    console.log(`[prod] 已加载 .env.all (${Object.keys(envFromAll).length} 项)`);
  }
}

function runChecked(command, args, options = {}) {
  const result = spawnSync(command, args, {
    cwd: ROOT,
    env: process.env,
    encoding: "utf8",
    stdio: options.stdio || "pipe",
    ...options,
  });
  if (result.error) {
    throw result.error;
  }
  if (result.status !== 0) {
    const stderr = result.stderr ? `\n${result.stderr.trim()}` : "";
    throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}${stderr}`);
  }
  return result.stdout || "";
}

function currentGitHead() {
  return runChecked("git", ["rev-parse", "HEAD"]).trim();
}

function parseVersion(version) {
  const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)$/);
  if (!match) return null;
  return match.slice(1).map((part) => Number(part));
}

function compareVersion(a, b) {
  const av = parseVersion(a);
  const bv = parseVersion(b);
  if (!av || !bv) return 0;
  for (let i = 0; i < 3; i += 1) {
    if (av[i] !== bv[i]) return av[i] - bv[i];
  }
  return 0;
}

function nextVersionAfter(version) {
  if (!version) return "0.0.1";
  let [major, minor, patch] = parseVersion(version) || [0, 0, 0];
  patch += 1;
  if (patch > 9) {
    patch = 0;
    minor += 1;
  }
  if (minor > 9) {
    minor = 0;
    major += 1;
  }
  return `${major}.${minor}.${patch}`;
}

async function pathExists(filePath) {
  try {
    await fsp.access(filePath);
    return true;
  } catch {
    return false;
  }
}

async function releaseRuntimeAssetsReady(sourceDir) {
  for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) {
    if (!(await pathExists(path.join(sourceDir, asset.target)))) {
      return false;
    }
  }
  return true;
}

async function listReleases() {
  if (!(await pathExists(RELEASE_ROOT))) return [];
  const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true });
  const releases = [];
  for (const entry of entries) {
    if (!entry.isDirectory() || !parseVersion(entry.name)) continue;
    const releaseDir = path.join(RELEASE_ROOT, entry.name);
    const metadataPath = path.join(releaseDir, "metadata.json");
    if (!(await pathExists(metadataPath))) {
      releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
      continue;
    }
    try {
      const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8"));
      const binaryPath = path.join(releaseDir, "mnote-web");
      const sourceDir = path.join(releaseDir, "source");
      const usable = (await pathExists(binaryPath))
        && (await pathExists(sourceDir))
        && metadata.buildSchema === RELEASE_BUILD_SCHEMA
        && (await releaseRuntimeAssetsReady(sourceDir));
      releases.push({ version: entry.name, releaseDir, metadata, usable });
    } catch {
      releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
    }
  }
  releases.sort((a, b) => compareVersion(a.version, b.version));
  return releases;
}

async function findReleaseForHead(head) {
  const releases = await listReleases();
  return releases.find((release) => release.usable && release.metadata?.gitHead === head) || null;
}

async function nextReleaseVersion() {
  const releases = await listReleases();
  let version = releases.length > 0 ? releases[releases.length - 1].version : null;
  let next = nextVersionAfter(version);
  while (await pathExists(path.join(RELEASE_ROOT, next))) {
    next = nextVersionAfter(next);
  }
  return next;
}

function pipeGitArchive(head, destination) {
  return new Promise((resolve, reject) => {
    const git = spawn("git", ["archive", "--format=tar", head], {
      cwd: ROOT,
      stdio: ["ignore", "pipe", "inherit"],
    });
    const tar = spawn("tar", ["-x", "-C", destination], {
      cwd: ROOT,
      stdio: ["pipe", "inherit", "inherit"],
    });

    git.stdout.pipe(tar.stdin);

    let gitCode = null;
    let tarCode = null;
    const maybeDone = () => {
      if (gitCode === null || tarCode === null) return;
      if (gitCode !== 0) {
        reject(new Error(`git archive failed with code ${gitCode}`));
        return;
      }
      if (tarCode !== 0) {
        reject(new Error(`tar extract failed with code ${tarCode}`));
        return;
      }
      resolve();
    };

    git.on("error", reject);
    tar.on("error", reject);
    git.on("close", (code) => {
      gitCode = code;
      maybeDone();
    });
    tar.on("close", (code) => {
      tarCode = code;
      maybeDone();
    });
  });
}

async function copyRuntimeAsset(sourceDir, asset) {
  const from = path.join(ROOT, asset.source);
  const to = path.join(sourceDir, asset.target);
  if (!(await pathExists(from))) {
    throw new Error(`缺少生产运行时资产：${asset.source}，请先在仓库根目录运行 npm install`);
  }
  await fsp.mkdir(path.dirname(to), { recursive: true });
  await fsp.copyFile(from, to);
}

async function copySupplementalRuntimeAssets(sourceDir) {
  console.log("[prod] 打包 Office/PDF/思维导图运行时资产 ...");
  for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) {
    await copyRuntimeAsset(sourceDir, asset);
  }
}

async function buildRelease(head) {
  const version = await nextReleaseVersion();
  const releaseDir = path.join(RELEASE_ROOT, version);
  const sourceDir = path.join(releaseDir, "source");
  const binaryPath = path.join(releaseDir, "mnote-web");
  const metadataPath = path.join(releaseDir, "metadata.json");

  await fsp.mkdir(sourceDir, { recursive: true });
  console.log(`[prod] 创建 release ${version}: ${releaseDir}`);
  await pipeGitArchive(head, sourceDir);
  await copySupplementalRuntimeAssets(sourceDir);

  const targetDir = path.join(BUILD_CACHE_DIR, version, "target");
  await fsp.mkdir(targetDir, { recursive: true });

  console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`);
  runChecked(
    "cargo",
    ["build", "--manifest-path", "rust/Cargo.toml", "-p", "mnote-web", "--bin", "mnote-web", "--release"],
    {
      cwd: sourceDir,
      env: {
        ...process.env,
        CARGO_TARGET_DIR: targetDir,
      },
      stdio: "inherit",
    },
  );

  const builtBinary = path.join(targetDir, "release", "mnote-web");
  await fsp.copyFile(builtBinary, binaryPath);
  await fsp.chmod(binaryPath, 0o755);

  const metadata = {
    schema: "mnote.prod_release.v1",
    buildSchema: RELEASE_BUILD_SCHEMA,
    version,
    gitHead: head,
    builtAt: new Date().toISOString(),
    binaryPath,
    sourceDir,
    cargoTargetDir: targetDir,
    runtimeAssets: SUPPLEMENTAL_RUNTIME_ASSETS.map((asset) => asset.target),
  };
  await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
  await fsp.rm(targetDir, { recursive: true, force: true });
  return { version, releaseDir, metadata, usable: true };
}

function readJsonIfExists(filePath) {
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf8"));
  } catch {
    return null;
  }
}

function isProcessAlive(pid) {
  if (!Number.isInteger(pid) || pid <= 0) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

function processCommandLine(pid) {
  try {
    return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
  } catch {
    return "";
  }
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function isPrivateLanAddress(address) {
  if (/^10\./.test(address)) return true;
  if (/^192\.168\./.test(address)) return true;
  const match = address.match(/^172\.(\d+)\./);
  if (!match) return false;
  const second = Number(match[1]);
  return second >= 16 && second <= 31;
}

function lanAddresses() {
  const ignoredInterface = /^(docker|br-|veth|virbr|tailscale|zt|lo)/;
  const addresses = [];
  for (const [name, entries] of Object.entries(os.networkInterfaces())) {
    if (ignoredInterface.test(name)) continue;
    for (const entry of entries || []) {
      if (entry.family !== "IPv4" || entry.internal) continue;
      if (!isPrivateLanAddress(entry.address)) continue;
      addresses.push(entry.address);
    }
  }
  return [...new Set(addresses)];
}

function defaultPublicBind(port) {
  return `${lanAddresses()[0] || "127.0.0.1"}:${port}`;
}

async function stopProcess(pid) {
  if (!isProcessAlive(pid)) return;
  console.log(`[prod] 停止旧进程 pid=${pid}`);
  process.kill(pid, "SIGTERM");
  for (let i = 0; i < 40; i += 1) {
    if (!isProcessAlive(pid)) return;
    await sleep(250);
  }
  if (isProcessAlive(pid)) {
    console.log(`[prod] 旧进程未及时退出，发送 SIGKILL pid=${pid}`);
    process.kill(pid, "SIGKILL");
  }
}

function isManagedMnoteProcess(pid, expectedBinaryPath = "") {
  const commandLine = processCommandLine(pid);
  if (!commandLine) return false;
  if (expectedBinaryPath && commandLine.includes(expectedBinaryPath)) return true;
  return commandLine.includes(RELEASE_ROOT) || /\bmnote-web\b/.test(commandLine);
}

function pidsListeningOnPort(port) {
  const pids = new Set();
  try {
    const out = execFileSync("ss", ["-ltnp", `sport = :${port}`], { encoding: "utf8" });
    for (const match of out.matchAll(/pid=(\d+)/g)) {
      pids.add(Number(match[1]));
    }
  } catch {
    // ignore
  }
  if (pids.size > 0) return [...pids];

  try {
    const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" });
    for (const line of out.split(/\r?\n/)) {
      const pid = Number(line.trim());
      if (Number.isInteger(pid) && pid > 0) pids.add(pid);
    }
  } catch {
    // ignore
  }
  return [...pids];
}

async function stopPreviousProcess(port) {
  const state = readJsonIfExists(RUN_STATE_PATH);
  if (state?.pid) {
    const pid = Number(state.pid);
    if (isManagedMnoteProcess(pid, state.binaryPath || "")) {
      await stopProcess(pid);
    }
  }

  for (const pid of pidsListeningOnPort(port)) {
    if (isManagedMnoteProcess(pid)) {
      await stopProcess(pid);
      continue;
    }
    const commandLine = processCommandLine(pid);
    throw new Error(`端口 ${port} 已被非 mnote-web 进程占用: pid=${pid} ${commandLine}`);
  }
}

function requestLocalAuth(port) {
  return new Promise((resolve) => {
    const req = http.get(
      {
        host: "127.0.0.1",
        port,
        path: "/auth",
        timeout: 1000,
      },
      (res) => {
        res.resume();
        resolve(res.statusCode && res.statusCode < 500);
      },
    );
    req.on("timeout", () => {
      req.destroy();
      resolve(false);
    });
    req.on("error", () => resolve(false));
  });
}

function requestLocalPath(port, requestPath) {
  return new Promise((resolve) => {
    const req = http.get(
      {
        host: "127.0.0.1",
        port,
        path: requestPath,
        timeout: 1000,
      },
      (res) => {
        res.resume();
        resolve(res.statusCode && res.statusCode >= 200 && res.statusCode < 300);
      },
    );
    req.on("timeout", () => {
      req.destroy();
      resolve(false);
    });
    req.on("error", () => resolve(false));
  });
}

async function waitForReady(port, logPath, pid) {
  for (let i = 0; i < 60; i += 1) {
    if (!isProcessAlive(pid)) break;
    if (await requestLocalAuth(port)) return;
    await sleep(250);
  }
  let logTail = "";
  try {
    const content = await fsp.readFile(logPath, "utf8");
    logTail = content.split(/\r?\n/).slice(-40).join(os.EOL);
  } catch {
    // ignore
  }
  throw new Error(`mnote-web 启动失败或未就绪，日志：\n${logTail}`);
}

async function verifyStartupAssets(port) {
  const failed = [];
  for (const requestPath of STARTUP_ASSET_PROBES) {
    // eslint-disable-next-line no-await-in-loop
    const ok = await requestLocalPath(port, requestPath);
    if (!ok) failed.push(requestPath);
  }
  if (failed.length > 0) {
    throw new Error(`生产运行时资产探测失败：${failed.join(", ")}`);
  }
}

async function startRelease(release) {
  const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT);
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
    throw new Error(`MNOTE_PROD_PORT 非法: ${process.env.MNOTE_PROD_PORT}`);
  }

  await fsp.mkdir(RUN_DIR, { recursive: true });
  await stopPreviousProcess(port);

  const binaryPath = path.join(release.releaseDir, "mnote-web");
  const logPath = path.join(release.releaseDir, "mnote-web.log");
  const logFd = fs.openSync(logPath, "a");
  const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
  const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);

  const child = spawn(binaryPath, [], {
    cwd: release.metadata?.sourceDir || release.releaseDir,
    detached: true,
    stdio: ["ignore", logFd, logFd],
    env: {
      ...process.env,
      MNOTE_WEB_BIND: bind,
      MNOTE_WEB_PUBLIC_BIND: publicBind,
      MNOTE_CONTROL_PLANE_DB_PATH:
        process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB,
    },
  });

  child.unref();
  fs.closeSync(logFd);

  const state = {
    schema: "mnote.prod_process.v1",
    pid: child.pid,
    version: release.version,
    gitHead: release.metadata?.gitHead,
    startedAt: new Date().toISOString(),
    bind,
    publicBind,
    releaseDir: release.releaseDir,
    binaryPath,
    logPath,
  };
  await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
  await waitForReady(port, logPath, child.pid);
  await verifyStartupAssets(port);

  console.log(`[prod] 已启动 mnote-web release ${release.version}`);
  console.log(`[prod] pid=${child.pid}`);
  console.log(`[prod] local URL=http://127.0.0.1:${port}`);
  for (const address of lanAddresses()) {
    console.log(`[prod] LAN URL=http://${address}:${port}`);
  }
  console.log(`[prod] log=${logPath}`);
}

async function main() {
  loadProdEnv();
  const head = currentGitHead();
  await fsp.mkdir(RELEASE_ROOT, { recursive: true });

  let release = await findReleaseForHead(head);
  if (release) {
    console.log(`[prod] 当前 HEAD ${head.slice(0, 12)} 已包含在 release ${release.version}，跳过 build`);
  } else {
    release = await buildRelease(head);
  }

  await startRelease(release);
}

if (require.main === module) {
  main().catch((error) => {
    console.error(`[prod] ${error.message}`);
    process.exit(1);
  });
}
