import {
  recoverExecutionBody,
  stripTypeScript,
  type CodeExecutor,
  type ExecuteResult,
  type SandboxToolInvoker,
} from "@executor-js/codemode-core";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import {
  getQuickJS,
  shouldInterruptAfterDeadline,
  type QuickJSContext,
  type QuickJSDeferredPromise,
  type QuickJSHandle,
  type QuickJSRuntime,
  type QuickJSWASMModule,
} from "quickjs-emscripten";

export type QuickJsExecutorOptions = {
  timeoutMs?: number;
  memoryLimitBytes?: number;
  maxStackSizeBytes?: number;
};

// Allow pre-loading a QuickJS module (e.g. with custom WASM bytes for compiled binaries)
let preloadedModule: QuickJSWASMModule | null = null;

export const setQuickJSModule = (mod: QuickJSWASMModule) => {
  preloadedModule = mod;
};

const resolveQuickJS = (): Promise<QuickJSWASMModule> =>
  preloadedModule ? Promise.resolve(preloadedModule) : getQuickJS();

class QuickJsExecutionError extends Data.TaggedError("QuickJsExecutionError")<{
  readonly message: string;
}> {}

// Large OpenAPI specs can take longer to parse inside QuickJS, so keep the
// default execution budget at five minutes unless a caller opts into less.
const DEFAULT_TIMEOUT_MS = 5 * 60_000;
const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_STACK_SIZE_BYTES = 1 * 1024 * 1024;
const EXECUTION_FILENAME = "executor-quickjs-runtime.js";

const toError = (cause: unknown): Error =>
  cause instanceof Error ? cause : new Error(String(cause));

// Defect surfaced to the sandbox when a tool dispatch reaches the
// reject path. Two sources reach here:
//   - `ExecutionToolError` emitted by the execution package — message
//     is already either the canonical
//     `Internal tool error [<corrId>]` opaque generic (plugin defects
//     passed through `makeExecutorToolInvoker`'s catchCause) or an
//     intentional public string (validators in engine.ts:
//     "tools.search expects an object: ...").
//   - Anything else — assume infra defect and emit a bare opaque
//     generic.
// `_tag === "ExecutionToolError"` is the in-band signal that the
// message was generated by code we control and is safe to pass
// through; we never read `.cause` / `.stack`.
const sandboxDefectMessage = (cause: unknown): string => {
  if (cause !== null && typeof cause === "object") {
    const tagged = cause as { readonly _tag?: unknown; readonly message?: unknown };
    // oxlint-disable-next-line executor/no-manual-tag-check, executor/no-unknown-error-message -- boundary: QuickJS reject path receives an already-rendered ExecutionToolError; we pass its in-band public message through and otherwise emit the opaque generic
    if (tagged._tag === "ExecutionToolError" && typeof tagged.message === "string") {
      return tagged.message;
    }
  }
  return "Internal tool error";
};

const toErrorMessage = (cause: unknown): string => {
  if (typeof cause === "object" && cause !== null) {
    const message =
      "message" in cause && typeof cause.message === "string" ? cause.message : undefined;

    if (message) {
      return message;
    }

    const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;

    if (stack) {
      return stack;
    }
  }

  const error = toError(cause);
  return error.stack ?? error.message;
};

const serializeJson = (value: unknown, label: string): string | undefined => {
  if (typeof value === "undefined") {
    return undefined;
  }

  try {
    return JSON.stringify(value);
  } catch (cause) {
    throw new Error(`${label} is not JSON serializable: ${toError(cause).message}`);
  }
};

const looksLikeInterruptedError = (message: string): boolean => /\binterrupted\b/i.test(message);

const timeoutMessage = (timeoutMs: number): string =>
  `QuickJS execution timed out after ${timeoutMs}ms`;

const normalizeExecutionError = (cause: unknown, deadlineMs: number, timeoutMs: number): string => {
  const message = toErrorMessage(cause);
  return Date.now() >= deadlineMs && looksLikeInterruptedError(message)
    ? timeoutMessage(timeoutMs)
    : message;
};

const buildExecutionSource = (code: string): string => {
  // QuickJS evaluates plain JavaScript only; strip any TS type syntax
  // first. A parse failure here throws a SyntaxError which the outer
  // `Effect.tryPromise` maps to `QuickJsExecutionError` with the
  // sucrase-formatted message intact.
  const body = stripTypeScript(recoverExecutionBody(code));

  return [
    '"use strict";',
    "const __invokeTool = __executor_invokeTool;",
    "const __log = __executor_log;",
    "try { delete globalThis.__executor_invokeTool; } catch {}",
    "try { delete globalThis.__executor_log; } catch {}",
    "const __formatLogArg = (value) => {",
    "  if (typeof value === 'string') return value;",
    "  try {",
    "    return JSON.stringify(value);",
    "  } catch {",
    "    return String(value);",
    "  }",
    "};",
    "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",
    "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
    "  get(_target, prop) {",
    "    if (prop === 'then' || typeof prop === 'symbol') {",
    "      return undefined;",
    "    }",
    "    return __makeToolsProxy([...path, String(prop)]);",
    "  },",
    "  apply(_target, _thisArg, args) {",
    "    const toolPath = path.join('.');",
    "    if (!toolPath) {",
    "      throw new Error('Tool path missing in invocation');",
    "    }",
    "    return Promise.resolve(__invokeTool(toolPath, args[0])).then((raw) => raw === undefined ? undefined : JSON.parse(raw));",
    "  },",
    "});",
    "const tools = __makeToolsProxy();",
    "const console = {",
    "  log: (...args) => __log('log', __formatLogLine(args)),",
    "  warn: (...args) => __log('warn', __formatLogLine(args)),",
    "  error: (...args) => __log('error', __formatLogLine(args)),",
    "  info: (...args) => __log('info', __formatLogLine(args)),",
    "  debug: (...args) => __log('debug', __formatLogLine(args)),",
    "};",
    "const fetch = (..._args) => {",
    "  throw new Error('fetch is disabled in QuickJS executor');",
    "};",
    "(async () => {",
    body,
    "})()",
  ].join("\n");
};

const readPropDump = (context: QuickJSContext, handle: QuickJSHandle, key: string): unknown => {
  const prop = context.getProp(handle, key);
  try {
    return context.dump(prop);
  } finally {
    prop.dispose();
  }
};

const readResultState = (
  context: QuickJSContext,
  handle: QuickJSHandle,
): {
  settled: boolean;
  value: unknown;
  error: unknown;
} => ({
  settled: readPropDump(context, handle, "settled") === true,
  value: readPropDump(context, handle, "v"),
  error: readPropDump(context, handle, "e"),
});

const createLogBridge = (context: QuickJSContext, logs: string[]): QuickJSHandle =>
  context.newFunction("__executor_log", (levelHandle, lineHandle) => {
    const level = context.getString(levelHandle);
    const line = context.getString(lineHandle);
    logs.push(`[${level}] ${line}`);
    return context.undefined;
  });

type RunPromise = <A, E>(effect: Effect.Effect<A, E>) => Promise<A>;

const createToolBridge = (
  context: QuickJSContext,
  toolInvoker: SandboxToolInvoker,
  pendingDeferreds: Set<QuickJSDeferredPromise>,
  runPromise: RunPromise,
): QuickJSHandle =>
  context.newFunction("__executor_invokeTool", (pathHandle, argsHandle) => {
    const path = context.getString(pathHandle);
    const args =
      argsHandle === undefined || context.typeof(argsHandle) === "undefined"
        ? undefined
        : context.dump(argsHandle);
    const deferred = context.newPromise();
    pendingDeferreds.add(deferred);
    deferred.settled.finally(() => {
      pendingDeferreds.delete(deferred);
    });

    void runPromise(toolInvoker.invoke({ path, args })).then(
      (value) => {
        if (!deferred.alive) {
          return;
        }

        const serialized = serializeJson(value, `Tool result for ${path}`);
        if (typeof serialized === "undefined") {
          deferred.resolve();
          return;
        }

        const valueHandle = context.newString(serialized);
        deferred.resolve(valueHandle);
        valueHandle.dispose();
      },
      (cause) => {
        if (!deferred.alive) {
          return;
        }

        // The reject path is reserved for true infra defects. The
        // upstream tool-invoker has already replaced the message with
        // an opaque generic plus a correlation id, but defensively log
        // the cause here and emit a stable generic if upstream changes.
        const message = sandboxDefectMessage(cause);
        try {
          console.error("[executor:quickjs] tool dispatch defect", { path, cause });
        } catch {
          /* ignore logger failures */
        }
        const errorHandle = context.newError(message);
        deferred.reject(errorHandle);
        errorHandle.dispose();
      },
    );

    return deferred.handle;
  });

const drainJobs = (
  context: QuickJSContext,
  runtime: QuickJSRuntime,
  deadlineMs: number,
  timeoutMs: number,
): void => {
  while (runtime.hasPendingJob()) {
    if (Date.now() >= deadlineMs) {
      throw new Error(timeoutMessage(timeoutMs));
    }

    const pending = runtime.executePendingJobs();
    if (pending.error) {
      const error = context.dump(pending.error);
      pending.error.dispose();
      throw toError(error);
    }
  }
};

const waitForDeferreds = async (
  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,
  deadlineMs: number,
  timeoutMs: number,
): Promise<void> => {
  const remainingMs = deadlineMs - Date.now();
  if (remainingMs <= 0) {
    throw new Error(timeoutMessage(timeoutMs));
  }

  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    await Promise.race([
      Promise.race([...pendingDeferreds].map((deferred) => deferred.settled)),
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error(timeoutMessage(timeoutMs))), remainingMs);
      }),
    ]);
  } finally {
    if (timer !== undefined) {
      clearTimeout(timer);
    }
  }
};

const drainAsync = async (
  context: QuickJSContext,
  runtime: QuickJSRuntime,
  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,
  deadlineMs: number,
  timeoutMs: number,
): Promise<void> => {
  drainJobs(context, runtime, deadlineMs, timeoutMs);

  while (pendingDeferreds.size > 0) {
    await waitForDeferreds(pendingDeferreds, deadlineMs, timeoutMs);
    drainJobs(context, runtime, deadlineMs, timeoutMs);
  }

  drainJobs(context, runtime, deadlineMs, timeoutMs);
};

const evaluateInQuickJs = async (
  options: QuickJsExecutorOptions,
  code: string,
  toolInvoker: SandboxToolInvoker,
  runPromise: RunPromise,
): Promise<ExecuteResult> => {
  const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
  const deadlineMs = Date.now() + timeoutMs;
  const logs: string[] = [];
  const pendingDeferreds = new Set<QuickJSDeferredPromise>();
  const QuickJS = await resolveQuickJS();
  const runtime = QuickJS.newRuntime();

  try {
    runtime.setMemoryLimit(options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);
    runtime.setMaxStackSize(options.maxStackSizeBytes ?? DEFAULT_MAX_STACK_SIZE_BYTES);

    runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));

    const context = runtime.newContext();
    try {
      const logBridge = createLogBridge(context, logs);
      context.setProp(context.global, "__executor_log", logBridge);
      logBridge.dispose();

      const toolBridge = createToolBridge(context, toolInvoker, pendingDeferreds, runPromise);
      context.setProp(context.global, "__executor_invokeTool", toolBridge);
      toolBridge.dispose();

      const evaluated = context.evalCode(buildExecutionSource(code), EXECUTION_FILENAME);
      if (evaluated.error) {
        const error = context.dump(evaluated.error);
        evaluated.error.dispose();
        return {
          result: null,
          error: normalizeExecutionError(error, deadlineMs, timeoutMs),
          logs,
        } satisfies ExecuteResult;
      }

      context.setProp(context.global, "__executor_result", evaluated.value);
      evaluated.value.dispose();

      const stateResult = context.evalCode(
        "(function(p){ var s = { v: void 0, e: void 0, settled: false }; var formatError = function(e){ if (e && typeof e === 'object') { var message = typeof e.message === 'string' ? e.message : ''; var stack = typeof e.stack === 'string' ? e.stack : ''; if (message && stack) { return stack.indexOf(message) === -1 ? message + '\\n' + stack : stack; } if (message) return message; if (stack) return stack; } return String(e); }; p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = formatError(e); s.settled = true; }); return s; })(__executor_result)",
      );
      if (stateResult.error) {
        const error = context.dump(stateResult.error);
        stateResult.error.dispose();
        return {
          result: null,
          error: normalizeExecutionError(error, deadlineMs, timeoutMs),
          logs,
        } satisfies ExecuteResult;
      }

      const stateHandle = stateResult.value;
      try {
        await drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs);
        const state = readResultState(context, stateHandle);
        if (!state.settled) {
          return {
            result: null,
            error: timeoutMessage(timeoutMs),
            logs,
          } satisfies ExecuteResult;
        }

        if (typeof state.error !== "undefined") {
          return {
            result: null,
            error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),
            logs,
          } satisfies ExecuteResult;
        }

        return {
          result: state.value,
          logs,
        } satisfies ExecuteResult;
      } finally {
        stateHandle.dispose();
      }
    } finally {
      for (const deferred of pendingDeferreds) {
        if (deferred.alive) {
          deferred.dispose();
        }
      }

      pendingDeferreds.clear();
      context.dispose();
    }
  } catch (cause) {
    return {
      result: null,
      error: normalizeExecutionError(cause, deadlineMs, timeoutMs),
      logs,
    } satisfies ExecuteResult;
  } finally {
    runtime.dispose();
  }
};

const runInQuickJs = (
  options: QuickJsExecutorOptions,
  code: string,
  toolInvoker: SandboxToolInvoker,
): Effect.Effect<ExecuteResult, QuickJsExecutionError> =>
  Effect.gen(function* () {
    const context = yield* Effect.context<never>();
    const runPromise = Effect.runPromiseWith(context);
    return yield* Effect.tryPromise({
      try: () => evaluateInQuickJs(options, code, toolInvoker, runPromise),
      catch: (cause) => new QuickJsExecutionError({ message: String(cause) }),
    });
  }).pipe(
    Effect.withSpan("executor.code.exec.quickjs", {
      attributes: { "executor.runtime": "quickjs" },
    }),
  );

export const makeQuickJsExecutor = (
  options: QuickJsExecutorOptions = {},
): CodeExecutor<QuickJsExecutionError> => ({
  execute: (code: string, toolInvoker: SandboxToolInvoker) =>
    runInQuickJs(options, code, toolInvoker),
});
