"use strict";

// This is a synchronizer, not a deployment executor.  It has no command field,
// no shell invocation, and no route to production release actions.
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const path = require("node:path");
const { spawn } = require("node:child_process");

function loadConfig() {
  const configPath = process.env.FIFTH_DOMAIN_SYNC_CONFIG;
  if (!configPath) throw new Error("FIFTH_DOMAIN_SYNC_CONFIG is required");
  const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
  for (const key of ["repo_path", "repository_full_name", "allowed_ref", "audit_dir", "lock_file", "navigation_guard"]) {
    if (!config[key]) throw new Error(`missing config field: ${key}`);
  }
  return config;
}

function safeEqualHex(expected, supplied) {
  if (!/^[a-f0-9]{64}$/i.test(supplied || "")) return false;
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(supplied, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifySignature(secret, rawBody, header) {
  const supplied = String(header || "").replace(/^sha256=/i, "");
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return safeEqualHex(expected, supplied);
}

function validPush(payload, config) {
  return payload && payload.ref === config.allowed_ref &&
    payload.repository?.full_name === config.repository_full_name &&
    /^[a-f0-9]{40,64}$/i.test(payload.after || "");
}

function run(argv, options = {}) {
  return new Promise((resolve) => {
    const child = spawn(argv[0], argv.slice(1), {
      cwd: options.cwd,
      env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" },
      shell: false,
      stdio: ["ignore", "pipe", "pipe"]
    });
    let stdout = "";
    let stderr = "";
    const limit = options.outputLimit || 16 * 1024;
    child.stdout.on("data", (chunk) => { if (stdout.length < limit) stdout += chunk; });
    child.stderr.on("data", (chunk) => { if (stderr.length < limit) stderr += chunk; });
    const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout || 90000);
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ code: -1, stdout, stderr: `${stderr}${error.message}`, timed_out: false });
    });
    child.on("close", (code, signal) => {
      clearTimeout(timer);
      resolve({ code: code ?? -1, stdout, stderr, timed_out: signal === "SIGKILL" });
    });
  });
}

function acquireLock(lockFile) {
  fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o750 });
  const fd = fs.openSync(lockFile, "wx", 0o640);
  fs.writeFileSync(fd, `${process.pid}\n`);
  return () => {
    fs.closeSync(fd);
    try { fs.unlinkSync(lockFile); } catch (_) {}
  };
}

function writeReceipt(config, receipt) {
  fs.mkdirSync(config.audit_dir, { recursive: true, mode: 0o750 });
  const target = path.join(config.audit_dir, `sync-${Date.now()}.json`);
  fs.writeFileSync(target, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o640, flag: "wx" });
  return target;
}

async function gitValue(config, args) {
  const result = await run(["/usr/bin/git", "-C", config.repo_path, ...args], { timeout: 15000 });
  if (result.code !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.slice(0, 300)}`);
  return result.stdout.trim();
}

async function synchronize(config, payload) {
  if (!validPush(payload, config)) return { accepted: false, reason: "push_not_allowed" };
  let release;
  try { release = acquireLock(config.lock_file); }
  catch (_) { return { accepted: false, reason: "sync_locked" }; }

  const receipt = { event: "repository_sync", commit: payload.after, status: "failed", checked_at: new Date().toISOString() };
  try {
    const branch = await gitValue(config, ["symbolic-ref", "--short", "HEAD"]);
    if (branch !== config.allowed_ref.replace("refs/heads/", "")) throw new Error("working_copy_not_on_allowed_branch");
    const dirty = await run(["/usr/bin/git", "-C", config.repo_path, "diff", "--quiet"], { timeout: 15000 });
    if (dirty.code !== 0) throw new Error("working_copy_dirty");

    const before = await gitValue(config, ["rev-parse", "HEAD"]);
    const fetch = await run(["/usr/bin/git", "-C", config.repo_path, "fetch", "--quiet", "origin", branch], { timeout: config.max_execution_ms });
    if (fetch.code !== 0) throw new Error(`git_fetch_failed: ${fetch.stderr.slice(0, 300)}`);
    const fetched = await gitValue(config, ["rev-parse", "FETCH_HEAD"]);
    if (fetched !== payload.after) throw new Error("fetched_commit_does_not_match_signed_event");
    const merge = await run(["/usr/bin/git", "-C", config.repo_path, "merge", "--ff-only", "FETCH_HEAD"], { timeout: config.max_execution_ms });
    if (merge.code !== 0) throw new Error(`fast_forward_refused: ${merge.stderr.slice(0, 300)}`);
    const after = await gitValue(config, ["rev-parse", "HEAD"]);
    const guard = await run(["/usr/bin/python3", path.join(config.repo_path, config.navigation_guard), "--range", `${before}..${after}`], { cwd: config.repo_path, timeout: config.max_execution_ms });
    receipt.status = guard.code === 0 ? "synchronized" : "synchronized_with_navigation_warning";
    receipt.before = before;
    receipt.after = after;
    receipt.navigation_guard_exit_code = guard.code;
    receipt.navigation_guard_output = `${guard.stdout}${guard.stderr}`.slice(0, 4000);
  } catch (error) {
    receipt.reason = error.message;
  } finally {
    receipt.completed_at = new Date().toISOString();
    receipt.local_receipt = writeReceipt(config, receipt);
    release();
  }
  return { accepted: receipt.status !== "failed", receipt };
}

function createServer(config, secret) {
  return http.createServer((req, res) => {
    if (req.method === "GET" && req.url === (config.health_path || "/health")) {
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ ok: true, service: "guanghu-fifth-domain-sync-agent", mode: "sync-and-receipt-only", version: "1.0.0" }));
      return;
    }
    if (req.method !== "POST" || req.url !== (config.webhook_path || "/forgejo/sync")) return res.writeHead(404).end();
    const chunks = [];
    let size = 0;
    req.on("data", (chunk) => {
      size += chunk.length;
      if (size > (config.max_body_bytes || 1048576)) req.destroy();
      else chunks.push(chunk);
    });
    req.on("end", async () => {
      const raw = Buffer.concat(chunks);
      if (!verifySignature(secret, raw, req.headers["x-forgejo-signature"] || req.headers["x-gitea-signature"])) {
        res.writeHead(401, { "content-type": "application/json" });
        return res.end(JSON.stringify({ ok: false, error: "invalid_signature" }));
      }
      try {
        const result = await synchronize(config, JSON.parse(raw.toString("utf8")));
        res.writeHead(result.accepted ? 202 : 409, { "content-type": "application/json" });
        res.end(JSON.stringify({ ok: result.accepted, ...result }));
      } catch (error) {
        res.writeHead(500, { "content-type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "sync_agent_error" }));
      }
    });
  });
}

if (require.main === module) {
  const config = loadConfig();
  const secret = process.env.FORGEJO_WEBHOOK_SECRET;
  if (!secret || secret.length < 32) throw new Error("FORGEJO_WEBHOOK_SECRET must be at least 32 characters");
  createServer(config, secret).listen(config.listen_port || 3982, config.listen_host || "127.0.0.1", () => {
    console.log(`guanghu-fifth-domain-sync-agent listening on ${config.listen_host || "127.0.0.1"}:${config.listen_port || 3982}`);
  });
}

module.exports = { createServer, synchronize, validPush, verifySignature };
