#!/usr/bin/env node
/**
 * MVFrame DingTalk notify service.
 *
 * Secrets stay in the Node process. Browser code should only call this local
 * service with `MVFRAME_NOTIFY_TOKEN`, never hold the DingTalk webhook/secret.
 */
const http = require("http");
const https = require("https");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const DEFAULT_ENV_FILE = ".env.mvframe-notify";

function parseArgs(argv) {
  const out = {};
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (!arg.startsWith("--")) continue;
    const eq = arg.indexOf("=");
    if (eq > 0) {
      out[arg.slice(2, eq)] = arg.slice(eq + 1);
      continue;
    }
    const key = arg.slice(2);
    const next = argv[i + 1];
    if (next && !next.startsWith("--")) {
      out[key] = next;
      i += 1;
    } else {
      out[key] = true;
    }
  }
  return out;
}

function trimEnvValue(value) {
  let out = String(value ?? "").trim();
  if (out.length >= 2) {
    const first = out[0];
    const last = out[out.length - 1];
    if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
      out = out.slice(1, -1);
    }
  }
  return out;
}

function loadEnvFile(file) {
  if (!file || !fs.existsSync(file)) return;
  const content = fs.readFileSync(file, "utf8");
  for (const raw of content.split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line.startsWith("#")) continue;
    const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
    const eq = normalized.indexOf("=");
    if (eq <= 0) continue;
    const key = normalized.slice(0, eq).trim();
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
    if (process.env[key] == null) {
      process.env[key] = trimEnvValue(normalized.slice(eq + 1));
    }
  }
}

function splitList(value) {
  return String(value || "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function parseBoolean(value) {
  return /^(1|true|yes|on)$/i.test(String(value || ""));
}

function readStdin() {
  return new Promise((resolve, reject) => {
    let body = "";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", (chunk) => {
      body += chunk;
    });
    process.stdin.on("end", () => resolve(body));
    process.stdin.on("error", reject);
  });
}

function readRequestBody(req, limit = 64 * 1024) {
  return new Promise((resolve, reject) => {
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      body += chunk;
      if (Buffer.byteLength(body) > limit) {
        reject(new Error("request body too large"));
        req.destroy();
      }
    });
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });
}

function writeJson(res, status, payload, corsOrigin) {
  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Access-Control-Allow-Origin": corsOrigin,
    "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
    "Access-Control-Allow-Headers": "content-type,authorization,x-mvframe-notify-token",
  });
  if (status === 204) {
    res.end();
    return;
  }
  res.end(`${JSON.stringify(payload, null, 2)}\n`);
}

function sanitizeStatus(config) {
  return {
    provider: "dingtalk",
    enabled: Boolean(config.webhook),
    signed: Boolean(config.secret),
    port: config.port,
    host: config.host,
    hasWebhook: Boolean(config.webhook),
    hasSecret: Boolean(config.secret),
    hasToken: Boolean(config.token),
    atMobileCount: config.atMobiles.length,
    atUserIdCount: config.atUserIds.length,
    atAll: config.atAll,
    timeoutMs: config.timeoutMs,
  };
}

function buildDingTalkUrl(config) {
  const url = new URL(config.webhook);
  if (!config.secret) return url.toString();

  const timestamp = Date.now().toString();
  const sign = crypto
    .createHmac("sha256", config.secret)
    .update(`${timestamp}\n${config.secret}`, "utf8")
    .digest("base64");

  url.searchParams.set("timestamp", timestamp);
  url.searchParams.set("sign", sign);
  return url.toString();
}

function postJson(url, payload, timeoutMs) {
  return new Promise((resolve, reject) => {
    const target = new URL(url);
    const body = JSON.stringify(payload);
    const client = target.protocol === "https:" ? https : http;
    const req = client.request(
      target,
      {
        method: "POST",
        timeout: timeoutMs,
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(body),
        },
      },
      (response) => {
        let data = "";
        response.setEncoding("utf8");
        response.on("data", (chunk) => {
          data += chunk;
        });
        response.on("end", () => {
          let parsed = data;
          try {
            parsed = data ? JSON.parse(data) : null;
          } catch {
            // Keep raw response body.
          }
          resolve({
            status: response.statusCode || 0,
            data: parsed,
          });
        });
      },
    );

    req.on("timeout", () => {
      req.destroy(new Error(`dingtalk timeout after ${timeoutMs}ms`));
    });
    req.on("error", reject);
    req.write(body);
    req.end();
  });
}

async function sendDingTalk(config, message, options = {}) {
  if (!config.webhook) {
    return {
      ok: false,
      message: "missing DINGTALK_WEBHOOK",
    };
  }

  const atMobiles = Array.isArray(options.atMobiles)
    ? options.atMobiles
    : config.atMobiles;
  const atUserIds = Array.isArray(options.atUserIds)
    ? options.atUserIds
    : config.atUserIds;
  const atAll = options.atAll == null ? config.atAll : Boolean(options.atAll);

  const response = await postJson(
    buildDingTalkUrl(config),
    {
      msgtype: "text",
      text: {
        content: String(message || ""),
      },
      at: {
        atMobiles,
        atUserIds,
        isAtAll: atAll,
      },
    },
    config.timeoutMs,
  );

  const data = response.data;
  const ok =
    response.status >= 200 &&
    response.status < 300 &&
    (!data || typeof data !== "object" || data.errcode === undefined || data.errcode === 0);

  return {
    ok,
    httpStatus: response.status,
    code: data && typeof data === "object" ? data.errcode : undefined,
    message:
      data && typeof data === "object"
        ? data.errmsg || (ok ? "sent" : "dingtalk rejected")
        : ok
          ? "sent"
          : "dingtalk rejected",
    data,
  };
}

function getAuthToken(req) {
  const headerToken = req.headers["x-mvframe-notify-token"];
  if (typeof headerToken === "string" && headerToken.trim()) {
    return headerToken.trim();
  }

  const authorization = req.headers.authorization;
  if (typeof authorization === "string") {
    const match = authorization.match(/^Bearer\s+(.+)$/i);
    if (match) return match[1].trim();
  }

  return "";
}

function ensureAuthorized(req, config) {
  if (!config.token) return true;
  return getAuthToken(req) === config.token;
}

function parseMessageFromUrl(url) {
  return url.searchParams.get("msg") || url.searchParams.get("message") || "";
}

async function parseNotifyPayload(req, url) {
  if (req.method === "GET") {
    return {
      message: parseMessageFromUrl(url),
    };
  }

  const raw = await readRequestBody(req);
  const contentType = String(req.headers["content-type"] || "").toLowerCase();
  if (contentType.includes("application/json")) {
    const parsed = raw ? JSON.parse(raw) : {};
    return {
      message: parsed.message ?? parsed.msg ?? "",
      atMobiles: Array.isArray(parsed.atMobiles) ? parsed.atMobiles : undefined,
      atUserIds: Array.isArray(parsed.atUserIds) ? parsed.atUserIds : undefined,
      atAll: parsed.atAll,
    };
  }

  const params = new URLSearchParams(raw);
  return {
    message: params.get("message") || params.get("msg") || parseMessageFromUrl(url),
  };
}

function createConfig(args) {
  const envFile = path.resolve(
    process.cwd(),
    String(args.env || process.env.MVFRAME_NOTIFY_ENV || DEFAULT_ENV_FILE),
  );
  loadEnvFile(envFile);

  return {
    envFile,
    port: Number(args.port || process.env.MVFRAME_NOTIFY_PORT || process.env.NOTIFY_PORT || 3300),
    host: String(args.host || process.env.MVFRAME_NOTIFY_HOST || "127.0.0.1"),
    token: String(args.token || process.env.MVFRAME_NOTIFY_TOKEN || "").trim(),
    webhook: String(args.webhook || process.env.DINGTALK_WEBHOOK || "").trim(),
    secret: String(args.secret || process.env.DINGTALK_SECRET || "").trim(),
    atMobiles: splitList(args.atMobiles || process.env.DINGTALK_AT_MOBILES || ""),
    atUserIds: splitList(args.atUserIds || process.env.DINGTALK_AT_USER_IDS || ""),
    atAll: parseBoolean(args.atAll || process.env.DINGTALK_AT_ALL || ""),
    timeoutMs: Number(args.timeout || process.env.MVFRAME_NOTIFY_TIMEOUT_MS || 5000),
    corsOrigin: String(args.corsOrigin || process.env.MVFRAME_NOTIFY_CORS_ORIGIN || "*"),
  };
}

async function runOnce(config, args) {
  const message = args.message || args.msg || (await readStdin()).trim();
  if (!message) {
    console.error("[mvframe-notify] missing message");
    process.exit(1);
  }
  const result = await sendDingTalk(config, message);
  console.log(JSON.stringify(result, null, 2));
  process.exit(result.ok ? 0 : 1);
}

function startServer(config) {
  const server = http.createServer(async (req, res) => {
    const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);

    if (req.method === "OPTIONS") {
      writeJson(res, 204, null, config.corsOrigin);
      return;
    }

    if (url.pathname === "/health") {
      writeJson(res, 200, { ok: true, ...sanitizeStatus(config) }, config.corsOrigin);
      return;
    }

    if (url.pathname !== "/notify" && url.pathname !== "/push") {
      writeJson(res, 404, { ok: false, message: "not found" }, config.corsOrigin);
      return;
    }

    if (!ensureAuthorized(req, config)) {
      writeJson(res, 401, { ok: false, message: "unauthorized" }, config.corsOrigin);
      return;
    }

    try {
      const payload = await parseNotifyPayload(req, url);
      const message = String(payload.message || "").trim();
      if (!message) {
        writeJson(res, 400, { ok: false, message: "missing message" }, config.corsOrigin);
        return;
      }

      const push = await sendDingTalk(config, message, payload);
      writeJson(res, push.ok ? 200 : 502, { ok: push.ok, push }, config.corsOrigin);
    } catch (error) {
      writeJson(
        res,
        500,
        {
          ok: false,
          message: error.message || String(error),
        },
        config.corsOrigin,
      );
    }
  });

  server.listen(config.port, config.host, () => {
    const status = sanitizeStatus(config);
    console.log(`[mvframe-notify] DingTalk service: http://${config.host}:${config.port}`);
    console.log(
      `[mvframe-notify] status: webhook=${status.hasWebhook ? "set" : "missing"}, secret=${status.hasSecret ? "set" : "missing"}, token=${status.hasToken ? "enabled" : "disabled"}`,
    );
    console.log(`[mvframe-notify] env: ${path.relative(process.cwd(), config.envFile)}`);
  });

  return server;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const config = createConfig(args);

  if (args.once) {
    await runOnce(config, args);
    return;
  }

  startServer(config);
}

main().catch((error) => {
  console.error(error.message || error);
  process.exit(1);
});
