/* eslint-disable @typescript-eslint/no-require-imports */
"use strict";

const fs = require("fs");

/**
 * Esbuild plugin: rewrite every `setTimeout(...).unref()` site in the bundled
 * output to `(t=>{t&&t.unref&&t.unref()})(setTimeout(...))`.
 *
 * Why: the `@anthropic-ai/claude-agent-sdk` process-transport-close path and
 * the transitive `@modelcontextprotocol/sdk` stdio-close-wait both call
 * `.unref()` on a `setTimeout` handle. In Electron's renderer process the
 * timer object lacks `.unref()`, and calling it crashes the renderer at
 * teardown. The wrapper preserves Node-side semantics and is a safe no-op
 * in the renderer.
 *
 * Runs as an `onEnd` plugin so it sees the final, post-minify, on-disk
 * output. After rewriting it re-scans the file and throws if any
 * unmatched site remains, failing the build.
 */
const patchRendererUnsafeUnref = {
  name: "patch-renderer-unsafe-unref",
  setup(build) {
    build.onEnd((result) => {
      // Skip when the build itself failed — esbuild has already surfaced errors.
      if (result.errors && result.errors.length > 0) return;

      const outfile = build.initialOptions.outfile;
      if (!outfile) {
        throw new Error(
          "[patch-renderer-unsafe-unref] expected build.initialOptions.outfile to be set"
        );
      }

      let source;
      try {
        source = fs.readFileSync(outfile, "utf8");
      } catch (err) {
        throw new Error(`[patch-renderer-unsafe-unref] failed to read ${outfile}: ${err.message}`);
      }

      // The SDKs whose unsafe `.unref()` sites motivate this patch
      // (@anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk) only get
      // bundled once host code actually imports them. Until that happens the
      // bundle has no `setTimeout(...).unref()` sites — that's fine, the
      // patch becomes a no-op. The marker probe below distinguishes that
      // benign case from "SDK is bundled but the matcher failed."
      const sdkBundled = source.includes("@anthropic-ai/claude-agent-sdk");

      // Iterate: each pass finds all top-level `setTimeout(...).unref()`
      // sites, rewrites them right-to-left, and re-scans. Nested sites
      // (e.g. an inner `setTimeout(...).unref()` living inside an outer
      // setTimeout's argument list) are shadowed by their outer site on
      // any single pass, so without iteration the verifier complains they
      // remain. Cap at a few passes so a matcher bug can't infinite-loop.
      let out = source;
      let totalRewritten = 0;
      const MAX_PASSES = 8;
      for (let pass = 0; pass < MAX_PASSES; pass++) {
        const sites = findUnsafeSites(out);
        if (sites.length === 0) break;
        for (let i = sites.length - 1; i >= 0; i--) {
          const { setTimeoutStart, setTimeoutEnd, fullEnd } = sites[i];
          const setTimeoutCall = out.slice(setTimeoutStart, setTimeoutEnd);
          const wrapped = `(t=>{t&&t.unref&&t.unref()})(${setTimeoutCall})`;
          out = out.slice(0, setTimeoutStart) + wrapped + out.slice(fullEnd);
        }
        totalRewritten += sites.length;
      }

      if (totalRewritten === 0) {
        if (sdkBundled) {
          throw new Error(
            "[patch-renderer-unsafe-unref] @anthropic-ai/claude-agent-sdk " +
              "appears bundled but no `setTimeout(...).unref()` sites were " +
              "found. The matcher likely missed them after a minifier change."
          );
        }
         
        console.log("[patch-renderer-unsafe-unref] no sites to rewrite (SDK not bundled yet)");
        return;
      }

      // Verify: re-scan the final rewritten output. Any remaining site means
      // the scanner missed something or iteration didn't converge — fail
      // loud.
      const remaining = findUnsafeSites(out);
      if (remaining.length > 0) {
        throw new Error(
          `[patch-renderer-unsafe-unref] verifier found ${remaining.length} ` +
            "unsafe `setTimeout(...).unref()` site(s) still present after " +
            "rewrite. First site near offset " +
            remaining[0].setTimeoutStart +
            "."
        );
      }

      fs.writeFileSync(outfile, out, "utf8");
       
      console.log(`[patch-renderer-unsafe-unref] rewrote ${totalRewritten} site(s)`);
    });
  },
};

/**
 * Find every `setTimeout(<balanced>)\.unref\(\)` site in the source.
 * Returns an array sorted by offset.
 *
 * The scanner is string-literal-aware (single, double, backtick, and
 * line/block comments) so parentheses inside string contents don't
 * unbalance the count. Regex literals are intentionally not handled —
 * minified output rarely emits regex literals adjacent to `(` of a call,
 * and the SDK call sites are simple.
 */
function findUnsafeSites(source) {
  const NEEDLE = "setTimeout(";
  const sites = [];
  let i = 0;
  while (i <= source.length - NEEDLE.length) {
    const at = source.indexOf(NEEDLE, i);
    if (at === -1) break;
    // Reject identifier-prefixed matches like `mySetTimeout(` — the char before
    // must not be an identifier-continuation character or `.`.
    if (at > 0) {
      const prev = source.charCodeAt(at - 1);
      const isIdentChar =
        (prev >= 0x30 && prev <= 0x39) || // 0-9
        (prev >= 0x41 && prev <= 0x5a) || // A-Z
        (prev >= 0x61 && prev <= 0x7a) || // a-z
        prev === 0x24 || // $
        prev === 0x5f || // _
        prev === 0x2e; // .
      if (isIdentChar) {
        i = at + 1;
        continue;
      }
    }
    const setTimeoutStart = at;
    const argStart = at + NEEDLE.length;
    const closeIdx = findMatchingParen(source, argStart);
    if (closeIdx === -1) {
      i = at + 1;
      continue;
    }
    const setTimeoutEnd = closeIdx + 1; // one past the `)`
    if (source.startsWith(".unref()", setTimeoutEnd)) {
      sites.push({
        setTimeoutStart,
        setTimeoutEnd,
        fullEnd: setTimeoutEnd + ".unref()".length,
      });
      i = setTimeoutEnd + ".unref()".length;
    } else {
      i = at + 1;
    }
  }
  return sites;
}

/**
 * Given a position immediately after an opening `(`, return the index of
 * its matching `)`, accounting for nested parens, string literals
 * (`'`, `"`, `` ` ``), and `// ` / `/* ... *\/` comments. Returns -1 if
 * unbalanced.
 */
function findMatchingParen(source, start) {
  let depth = 1;
  let i = start;
  while (i < source.length) {
    const ch = source[i];
    if (ch === "(") {
      depth++;
      i++;
      continue;
    }
    if (ch === ")") {
      depth--;
      if (depth === 0) return i;
      i++;
      continue;
    }
    if (ch === "'" || ch === '"' || ch === "`") {
      i = skipString(source, i, ch);
      continue;
    }
    if (ch === "/" && i + 1 < source.length) {
      const next = source[i + 1];
      if (next === "/") {
        i = source.indexOf("\n", i + 2);
        if (i === -1) return -1;
        continue;
      }
      if (next === "*") {
        const end = source.indexOf("*/", i + 2);
        if (end === -1) return -1;
        i = end + 2;
        continue;
      }
    }
    i++;
  }
  return -1;
}

/**
 * Skip a string literal starting at `i` whose opening quote is `quote`.
 * Returns the index just past the closing quote. Handles backslash escapes.
 * For template literals, recursively skips `${...}` expressions.
 */
function skipString(source, i, quote) {
  i++; // past opening quote
  while (i < source.length) {
    const ch = source[i];
    if (ch === "\\") {
      i += 2;
      continue;
    }
    if (ch === quote) return i + 1;
    if (quote === "`" && ch === "$" && source[i + 1] === "{") {
      // Skip the `${ ... }` interpolation, balancing inner braces.
      let depth = 1;
      let j = i + 2;
      while (j < source.length && depth > 0) {
        const c = source[j];
        if (c === "{") depth++;
        else if (c === "}") depth--;
        else if (c === "'" || c === '"' || c === "`") {
          j = skipString(source, j, c);
          continue;
        }
        j++;
      }
      i = j;
      continue;
    }
    i++;
  }
  return i;
}

module.exports = patchRendererUnsafeUnref;
