/**
 * Workflow secrets — crypto primitives (no I/O, no DB).
 *
 * This module encapsulates the at-rest encryption used by `workflow_secrets`
 * rows and the `${{ secrets.NAME }}` template substitution applied at
 * step-execution time. The runner (Agent 5) calls `substituteSecrets` for
 * every step; the UI layer (Agent 7) calls `encryptSecret` when persisting a
 * new value and `decryptSecret` only via the DB loader (Agent 2, this file's
 * sibling `workflow-secrets.ts`).
 *
 * Crypto: AES-256-GCM with a single 32-byte master key sourced from
 * `process.env.WORKFLOW_SECRETS_KEY` (hex-encoded). The IV is a fresh random
 * 12 bytes per encryption; the GCM auth tag is the canonical 16 bytes. The
 * ciphertext blob on disk is base64("iv(12) || tag(16) || ciphertext").
 *
 * Every public fn in this file is synchronous and returns `{ok:true,...}` /
 * `{ok:false,error}` — never throws, even on malformed input. That's a hard
 * contract the rest of the workflow engine depends on (the runner cannot
 * afford to panic mid-step on a mis-encoded blob).
 */

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const ALGO = "aes-256-gcm";
const IV_LEN = 12; // GCM standard
const TAG_LEN = 16; // GCM default auth tag length
const KEY_LEN = 32; // AES-256

/**
 * Read the master key at call time (not module load). Returns `null` if the
 * env var is unset, not hex, or doesn't decode to exactly 32 bytes. Callers
 * must treat `null` as a soft failure — the caller's public surface should
 * return `{ok:false}`, never throw.
 *
 * We deliberately re-read the env on every call so tests can set the var,
 * run a case, then clear it, without needing to reload the module graph.
 */
export function getMasterKey(): Buffer | null {
  const hex = process.env.WORKFLOW_SECRETS_KEY;
  if (!hex) return null;
  // Tolerate leading/trailing whitespace from .env files and only hex chars.
  const trimmed = hex.trim();
  if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null;
  let buf: Buffer;
  try {
    buf = Buffer.from(trimmed, "hex");
  } catch {
    return null;
  }
  if (buf.length !== KEY_LEN) return null;
  return buf;
}

/**
 * Encrypt a plaintext secret under the master key.
 *
 * Returns a single base64 string on success. Layout:
 *   [0..12)   IV         (12 bytes, random)
 *   [12..28)  auth tag   (16 bytes, GCM)
 *   [28..)    ciphertext (same length as plaintext, since GCM is stream-y)
 *
 * Failure modes: master key missing / wrong length / node crypto throws
 * (extremely unlikely for in-memory AES). All collapse to `{ok:false,error}`.
 */
export function encryptSecret(
  plaintext: string
): { ok: true; ciphertext: string } | { ok: false; error: string } {
  const key = getMasterKey();
  if (!key) {
    return {
      ok: false,
      error:
        "WORKFLOW_SECRETS_KEY missing or not a 32-byte hex value (64 hex chars)",
    };
  }
  if (typeof plaintext !== "string") {
    return { ok: false, error: "plaintext must be a string" };
  }
  try {
    const iv = randomBytes(IV_LEN);
    const cipher = createCipheriv(ALGO, key, iv);
    const enc = Buffer.concat([
      cipher.update(plaintext, "utf8"),
      cipher.final(),
    ]);
    const tag = cipher.getAuthTag();
    if (tag.length !== TAG_LEN) {
      return { ok: false, error: "unexpected auth tag length" };
    }
    const blob = Buffer.concat([iv, tag, enc]);
    return { ok: true, ciphertext: blob.toString("base64") };
  } catch (err) {
    return {
      ok: false,
      error: `encrypt failed: ${err instanceof Error ? err.message : String(err)}`,
    };
  }
}

/**
 * Decrypt a ciphertext produced by `encryptSecret`.
 *
 * Never throws. Returns `{ok:false}` on:
 *   - master key missing / wrong length
 *   - input not valid base64
 *   - blob shorter than IV+tag (28 bytes)
 *   - GCM auth-tag mismatch (tampered ciphertext or wrong key)
 */
export function decryptSecret(
  ciphertext: string
): { ok: true; plaintext: string } | { ok: false; error: string } {
  const key = getMasterKey();
  if (!key) {
    return {
      ok: false,
      error:
        "WORKFLOW_SECRETS_KEY missing or not a 32-byte hex value (64 hex chars)",
    };
  }
  if (typeof ciphertext !== "string" || ciphertext.length === 0) {
    return { ok: false, error: "ciphertext must be a non-empty string" };
  }
  let blob: Buffer;
  try {
    blob = Buffer.from(ciphertext, "base64");
  } catch {
    return { ok: false, error: "ciphertext is not valid base64" };
  }
  // Buffer.from with "base64" silently drops invalid chars rather than
  // throwing; also verify the decoded length is plausible.
  if (blob.length < IV_LEN + TAG_LEN) {
    return { ok: false, error: "ciphertext blob too short" };
  }
  const iv = blob.subarray(0, IV_LEN);
  const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
  const enc = blob.subarray(IV_LEN + TAG_LEN);
  try {
    const decipher = createDecipheriv(ALGO, key, iv);
    decipher.setAuthTag(tag);
    const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
    return { ok: true, plaintext: dec.toString("utf8") };
  } catch (err) {
    return {
      ok: false,
      error: `decrypt failed: ${err instanceof Error ? err.message : String(err)}`,
    };
  }
}

/**
 * Substitute `${{ secrets.NAME }}` tokens in a template string.
 *
 * Matches (case-sensitive on NAME, flexible whitespace around the inner
 * `secrets.NAME` expression):
 *   `${{ secrets.NAME }}`
 *   `${{secrets.NAME}}`
 *   `${{  secrets.NAME  }}`
 *
 * Rules:
 *   - If `NAME` is a key in `secrets`, the entire token is replaced with the
 *     raw value (no quoting, no shell-escaping — that's the runner's job).
 *   - Unknown names are **left untouched**. This is intentional: an empty
 *     string would silently break commands (`curl -H "$TOKEN" ...`), and a
 *     throw would abort the run. Leaving the literal placeholder makes the
 *     failure visible in run logs.
 *   - `$${{ secrets.X }}` (double-dollar) is an escape — it renders as the
 *     literal `${{ secrets.X }}` with no lookup. Matches GitHub Actions /
 *     docker-compose convention.
 *   - Only `secrets.` is recognised here. `env.` / `matrix.` / `github.` are
 *     expression-context references owned by Agent 4's if-evaluator and by
 *     the runner step context; they pass through this fn untouched.
 */
export function substituteSecrets(
  template: string,
  secrets: Record<string, string>
): string {
  if (typeof template !== "string" || template.length === 0) return template;

  // Handle the escape form first by swapping it to a sentinel that cannot
  // appear in normal text (NUL bytes are stripped by most transports and
  // never legitimately appear in YAML we load). After the secrets pass we
  // swap the sentinel back to the literal token.
  const ESC_SENTINEL = " GLUECRON_SECRETS_ESCAPE ";
  // $${{ <anything-but-newline> }} — we only restore the literal form; we
  // don't actually care about the inner contents for the escape case.
  const escapeRe = /\$\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
  const escaped = template.replace(escapeRe, (_m, name: string) => {
    return `${ESC_SENTINEL}${name}${ESC_SENTINEL}`;
  });

  const tokenRe = /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
  const substituted = escaped.replace(tokenRe, (match, name: string) => {
    if (Object.prototype.hasOwnProperty.call(secrets, name)) {
      return secrets[name]!;
    }
    // Unknown name — leave the original token intact.
    return match;
  });

  // Restore escaped tokens as literal `${{ secrets.NAME }}`.
  const restoreRe = new RegExp(
    `${ESC_SENTINEL}([A-Za-z_][A-Za-z0-9_]*)${ESC_SENTINEL}`,
    "g"
  );
  return substituted.replace(restoreRe, (_m, name: string) => {
    return `\${{ secrets.${name} }}`;
  });
}
