/**
 * Shared utilities for abx plugins (JavaScript).
 *
 * Provides common helpers used across multiple plugins:
 * - Environment variable parsing (getEnv, getEnvBool, getEnvInt, getEnvArray)
 * - CLI argument parsing (parseArgs)
 * - JSONL record emission (emitArchiveResultRecord, emitSnapshotRecord)
 * - Atomic file writing (writeFileAtomic)
 * - Sibling plugin output checking (hasStaticFileOutput)
 */

const fs = require("fs");
const os = require("os");
const path = require("path");

const BASE_CONFIG_PATH = path.join(__dirname, "config.json");
const PROCESS_EXIT_SKIPPED = 10;
const INTERNAL_INPUT_URL = "archivebox://internal";
const configCache = new Map();

function fsyncIfRegularFile(fd) {
  try {
    const stats = fs.fstatSync(fd);
    if (stats.isFile()) {
      fs.fsyncSync(fd);
    }
  } catch (error) {
    return;
  }
}

function writeFdFully(fd, text) {
  const buffer = Buffer.from(text, "utf8");
  let offset = 0;
  while (offset < buffer.length) {
    offset += fs.writeSync(fd, buffer, offset, buffer.length - offset);
  }
  fsyncIfRegularFile(fd);
}

// ---------------------------------------------------------------------------
// Environment variable helpers
// ---------------------------------------------------------------------------

function getCallerFile(skipFn) {
  const original = Error.prepareStackTrace;
  try {
    Error.prepareStackTrace = (_, stack) => stack;
    const err = new Error();
    Error.captureStackTrace(err, skipFn);
    const stack = err.stack || [];
    for (const site of stack) {
      const fileName =
        site && typeof site.getFileName === "function"
          ? site.getFileName()
          : null;
      if (fileName && fileName !== __filename) {
        return fileName;
      }
    }
  } finally {
    Error.prepareStackTrace = original;
  }
  return null;
}

function normalizeSchemaType(prop = {}) {
  const rawType = prop.type || "string";
  if (Array.isArray(rawType)) {
    const nonNullTypes = rawType.filter((typeName) => typeName !== "null");
    return {
      schemaType: nonNullTypes[0] || "string",
      nullable: rawType.includes("null"),
    };
  }
  return {
    schemaType: rawType,
    nullable: false,
  };
}

function parseArrayValue(rawValue, defaultValue) {
  const trimmed = String(rawValue || "").trim();
  if (!trimmed) return defaultValue;
  if (trimmed.startsWith("[")) {
    try {
      const parsed = JSON.parse(trimmed);
      if (Array.isArray(parsed)) return parsed.map((item) => String(item));
    } catch (error) {}
  }
  return trimmed
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function parseConfigValue(rawValue, prop = {}) {
  const { schemaType, nullable } = normalizeSchemaType(prop);
  const defaultValue = Object.prototype.hasOwnProperty.call(prop, "default")
    ? prop.default
    : nullable
    ? null
    : undefined;

  if (rawValue === undefined) {
    return defaultValue;
  }

  const trimmed = String(rawValue).trim();
  if (nullable && trimmed === "") {
    return null;
  }

  if (schemaType === "boolean") {
    const lowered = trimmed.toLowerCase();
    if (["true", "1", "yes", "on"].includes(lowered)) return true;
    if (["false", "0", "no", "off"].includes(lowered)) return false;
    return defaultValue;
  }

  if (schemaType === "integer") {
    const parsed = parseInt(trimmed, 10);
    return Number.isNaN(parsed) ? defaultValue : parsed;
  }

  if (schemaType === "number") {
    const parsed = Number(trimmed);
    return Number.isNaN(parsed) ? defaultValue : parsed;
  }

  if (schemaType === "array") {
    return parseArrayValue(trimmed, defaultValue || []);
  }

  return trimmed;
}

function getPlatformUserConfigDir() {
  if (process.platform === "darwin") {
    return path.join(os.homedir(), "Library", "Application Support", "abx");
  }
  if (process.platform === "win32") {
    return path.join(
      process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
      "abx"
    );
  }
  return path.join(
    process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
    "abx"
  );
}

function hydrateConfigValue(value, context) {
  if (typeof value === "string") {
    return value.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, key) => {
      const replacement = context[key];
      if (replacement === undefined || replacement === null) return match;
      return String(replacement);
    });
  }
  if (Array.isArray(value)) {
    return value.map((item) => hydrateConfigValue(item, context));
  }
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [
        key,
        hydrateConfigValue(item, context),
      ])
    );
  }
  return value;
}

function resolveConfigPath(configPath = null) {
  if (configPath) return path.resolve(configPath);
  const callerFile = getCallerFile(loadConfig);
  if (!callerFile) return BASE_CONFIG_PATH;
  return path.join(path.dirname(path.resolve(callerFile)), "config.json");
}

function argvUrl() {
  const argv = process.argv.slice(2);
  for (let idx = 0; idx < argv.length; idx++) {
    const arg = String(argv[idx] || "");
    if (arg === "--url" && idx + 1 < argv.length) {
      return String(argv[idx + 1] || "").trim();
    }
    if (arg.startsWith("--url=")) {
      return arg.slice("--url=".length).trim();
    }
  }
  return "";
}

function maybeSkipUnsupportedSnapshotUrl(schema) {
  const scriptName = path.basename(process.argv[1] || process.argv[0] || "");
  const url = argvUrl();
  if (!scriptName.startsWith("on_Snapshot__") || !url) return;
  if (url.startsWith("http://") || url.startsWith("https://")) return;
  // ArchiveBox uses one synthetic snapshot URL for pasted/stdin import text.
  // Only plugins that explicitly opt in should consume that source; everything
  // else should no-result before starting browsers/downloaders or networking.
  if (url === INTERNAL_INPUT_URL && schema["x-accepts-internal-input"]) return;
  emitArchiveResultRecord("noresults", `unsupported input URL: ${url}`);
  process.exit(0);
}

function loadConfig(configPath = null) {
  const pluginConfigPath = resolveConfigPath(configPath);
  const pluginMtime = fs.statSync(pluginConfigPath).mtimeMs;
  const baseMtime = fs.statSync(BASE_CONFIG_PATH).mtimeMs;
  const cacheKey = pluginConfigPath;
  const cached = configCache.get(cacheKey);
  if (
    cached &&
    cached.pluginMtime === pluginMtime &&
    cached.baseMtime === baseMtime
  ) {
    return { ...cached.config };
  }

  const baseSchema = JSON.parse(fs.readFileSync(BASE_CONFIG_PATH, "utf8"));
  const pluginSchema =
    pluginConfigPath === BASE_CONFIG_PATH
      ? baseSchema
      : JSON.parse(fs.readFileSync(pluginConfigPath, "utf8"));
  maybeSkipUnsupportedSnapshotUrl(pluginSchema);

  const properties =
    pluginConfigPath === BASE_CONFIG_PATH
      ? baseSchema.properties || {}
      : {
          ...(baseSchema.properties || {}),
          ...(pluginSchema.properties || {}),
        };

  const config = {};
  for (
    let idx = 0;
    idx < Math.max(Object.keys(properties).length, 1) + 1;
    idx++
  ) {
    let changed = false;
    for (const [name, prop] of Object.entries(properties)) {
      const choices = [name, ...(prop["x-aliases"] || [])];
      if (prop["x-fallback"]) {
        choices.push(prop["x-fallback"]);
      }

      let rawValue;
      for (const choice of choices) {
        if (Object.prototype.hasOwnProperty.call(process.env, choice)) {
          rawValue = process.env[choice];
          break;
        }
      }

      let value = parseConfigValue(rawValue, prop);
      if (
        rawValue === undefined &&
        prop["x-fallback"] &&
        config[prop["x-fallback"]] !== undefined
      ) {
        value = config[prop["x-fallback"]];
      }
      value = hydrateConfigValue(value, config);
      if (config[name] !== value) {
        config[name] = value;
        changed = true;
      }
    }
    if (!changed) break;
  }

  if (!config.PERSONAS_DIR) {
    config.PERSONAS_DIR = path.join(getPlatformUserConfigDir(), "personas");
  }
  if (
    Object.prototype.hasOwnProperty.call(config, "CHROME_USER_DATA_DIR") &&
    !config.CHROME_USER_DATA_DIR
  ) {
    config.CHROME_USER_DATA_DIR = path.join(
      config.PERSONAS_DIR,
      config.ACTIVE_PERSONA || "Default",
      "chrome_profile"
    );
  }
  if (Object.prototype.hasOwnProperty.call(config, "CHROME_BINARY")) {
    const ciChromiumPath = "/usr/bin/chromium";
    const canaryPath =
      "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary";
    const hasExplicitBrowser = Object.prototype.hasOwnProperty.call(
      process.env,
      "CHROME_BINARY"
    );
    if (!hasExplicitBrowser) {
      if (fs.existsSync(ciChromiumPath)) {
        config.CHROME_BINARY = ciChromiumPath;
      } else if (process.platform === "darwin" && fs.existsSync(canaryPath)) {
        config.CHROME_BINARY = canaryPath;
      }
    }
  }

  configCache.set(cacheKey, {
    config,
    pluginMtime,
    baseMtime,
  });
  return { ...config };
}

function getConfig(configPath = null) {
  return loadConfig(configPath);
}

function resolveCallerConfigPath(skipFn) {
  const callerFile = getCallerFile(skipFn);
  if (!callerFile) return BASE_CONFIG_PATH;
  const callerConfigPath = path.join(
    path.dirname(path.resolve(callerFile)),
    "config.json"
  );
  return fs.existsSync(callerConfigPath) ? callerConfigPath : BASE_CONFIG_PATH;
}

function getConfigValue(name, defaultValue, skipFn) {
  try {
    const config = loadConfig(resolveCallerConfigPath(skipFn));
    if (Object.prototype.hasOwnProperty.call(config, name)) {
      return config[name];
    }
  } catch (error) {}

  if (Object.prototype.hasOwnProperty.call(process.env, name)) {
    return process.env[name];
  }
  return defaultValue;
}

function getEnv(name, defaultValue = "") {
  const value = getConfigValue(name, defaultValue, getEnv);
  if (value === null || value === undefined) return "";
  return String(value).trim();
}

function getEnvBool(name, defaultValue = false) {
  const value = getConfigValue(name, defaultValue, getEnvBool);
  if (typeof value === "boolean") return value;
  const val = String(value ?? "")
    .trim()
    .toLowerCase();
  if (["true", "1", "yes", "on"].includes(val)) return true;
  if (["false", "0", "no", "off"].includes(val)) return false;
  return defaultValue;
}

function getEnvInt(name, defaultValue = 0) {
  const value = getConfigValue(name, defaultValue, getEnvInt);
  if (typeof value === "number" && Number.isFinite(value)) return value;
  const val = parseInt(String(value ?? defaultValue), 10);
  return isNaN(val) ? defaultValue : val;
}

/**
 * Get array environment variable (JSON array or comma-separated string).
 *
 * If value starts with '[', parse as JSON array.
 * Otherwise, parse as comma-separated values.
 */
function getEnvArray(name, defaultValue = []) {
  const value = getConfigValue(name, defaultValue, getEnvArray);
  if (Array.isArray(value)) return value.map((item) => String(item));
  const val = String(value ?? "").trim();
  if (!val) return defaultValue;

  if (val.startsWith("[")) {
    try {
      const parsed = JSON.parse(val);
      if (Array.isArray(parsed)) return parsed;
    } catch (e) {
      // Warn when a value looks like JSON but fails to parse, then
      // fall through to comma-separated parsing below.
      writeFdFully(
        2,
        `[base/utils.js] Warning: ${name} looks like JSON but failed to parse: ${e.message}\n`
      );
    }
  }

  return val
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);
}

function getSnapDir() {
  const snapDir = getEnv("SNAP_DIR");
  if (snapDir) return path.resolve(snapDir);
  return path.resolve(process.cwd());
}

function getCrawlDir() {
  const crawlDir = getEnv("CRAWL_DIR");
  if (crawlDir) return path.resolve(crawlDir);
  return path.resolve(process.cwd());
}

function getLibDir() {
  const configured = (loadConfig(BASE_CONFIG_PATH).LIB_DIR || "").trim();
  if (configured) return path.resolve(configured);
  return path.resolve(path.join(getPlatformUserConfigDir(), "lib"));
}

function getPersonasDir() {
  const configured = (loadConfig(BASE_CONFIG_PATH).PERSONAS_DIR || "").trim();
  if (configured) return path.resolve(configured);
  return path.resolve(path.join(getPlatformUserConfigDir(), "personas"));
}

function getNodeModulesDir() {
  const configured = getEnv("NODE_MODULES_DIR") || getEnv("NODE_MODULE_DIR");
  if (configured) return path.resolve(configured);
  return path.resolve(
    path.join(getLibDir(), "pnpm", "packages", "chrome", "node_modules")
  );
}

function getChromeExtensionsDir() {
  const configured = (
    loadConfig(BASE_CONFIG_PATH).CHROME_EXTENSIONS_DIR || ""
  ).trim();
  if (configured) return path.resolve(configured);
  return path.resolve(path.join(getLibDir(), "chromewebstore", "extensions"));
}

function getMachineType() {
  if (process.env.MACHINE_TYPE) {
    return process.env.MACHINE_TYPE;
  }

  let machine = process.arch;
  const system = process.platform;

  if (machine === "arm64" || machine === "aarch64") {
    machine = "arm64";
  } else if (machine === "x64" || machine === "x86_64" || machine === "amd64") {
    machine = "x86_64";
  } else if (machine === "ia32" || machine === "x86") {
    machine = "x86";
  }

  return `${machine}-${system}`;
}

function getTestEnv() {
  const config = loadConfig(BASE_CONFIG_PATH);
  const libDir = getLibDir();
  const nodeModulesDir = getNodeModulesDir();

  return {
    SNAP_DIR: getSnapDir(),
    CRAWL_DIR: getCrawlDir(),
    PERSONAS_DIR: getPersonasDir(),
    ACTIVE_PERSONA: config.ACTIVE_PERSONA || "Default",
    MACHINE_TYPE: getMachineType(),
    LIB_DIR: libDir,
    NODE_MODULES_DIR: nodeModulesDir,
    NODE_PATH: nodeModulesDir,
    PNPM_BIN_DIR: path.join(
      libDir,
      "pnpm",
      "packages",
      "chrome",
      "node_modules",
      ".bin"
    ),
    NPM_BIN_DIR: path.join(
      libDir,
      "pnpm",
      "packages",
      "chrome",
      "node_modules",
      ".bin"
    ),
    CHROME_EXTENSIONS_DIR: getChromeExtensionsDir(),
  };
}

function ensureNodeModuleResolution(moduleRef = module) {
  const nodeModulesDir = getNodeModulesDir();

  if (!process.env.NODE_MODULES_DIR && process.env.NODE_MODULE_DIR) {
    process.env.NODE_MODULES_DIR = process.env.NODE_MODULE_DIR;
  }
  if (!process.env.NODE_MODULE_DIR && process.env.NODE_MODULES_DIR) {
    process.env.NODE_MODULE_DIR = process.env.NODE_MODULES_DIR;
  }
  if (!process.env.NODE_PATH) {
    process.env.NODE_PATH = nodeModulesDir;
  }

  if (!moduleRef.paths.includes(nodeModulesDir)) {
    moduleRef.paths.unshift(nodeModulesDir);
  }

  return nodeModulesDir;
}

// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------

/**
 * Parse --key=value arguments from process.argv.
 * Returns an object with keys (dashes converted to underscores).
 */
function parseArgs() {
  const args = {};
  process.argv.slice(2).forEach((arg) => {
    if (arg.startsWith("--")) {
      const [key, ...valueParts] = arg.slice(2).split("=");
      args[key.replace(/-/g, "_")] = valueParts.join("=") || true;
    }
  });
  return args;
}

// ---------------------------------------------------------------------------
// JSONL record emission
// ---------------------------------------------------------------------------

function parseExtraContext(raw, source) {
  try {
    const parsed = JSON.parse(raw);
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
      return parsed;
    }
    writeFdFully(
      2,
      `[base/utils.js] Warning: ignoring non-object extra context from ${source}\n`
    );
  } catch (error) {
    writeFdFully(
      2,
      `[base/utils.js] Warning: ignoring invalid extra context from ${source}: ${error.message}\n`
    );
  }
  return {};
}

function getExtraContext() {
  const context = {};
  const envRaw = (loadConfig(BASE_CONFIG_PATH).EXTRA_CONTEXT || "").trim();
  if (envRaw) {
    Object.assign(context, parseExtraContext(envRaw, "EXTRA_CONTEXT"));
  }

  const argv = process.argv.slice(2);
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === "--extra-context") {
      const value = argv[i + 1];
      if (value === undefined) {
        writeFdFully(
          2,
          "[base/utils.js] Warning: ignoring missing value for --extra-context\n"
        );
        return context;
      }
      Object.assign(context, parseExtraContext(value, "--extra-context"));
      return context;
    }
    if (arg.startsWith("--extra-context=")) {
      Object.assign(
        context,
        parseExtraContext(
          arg.slice("--extra-context=".length),
          "--extra-context"
        )
      );
      return context;
    }
  }

  return context;
}

function mergeExtraContext(record) {
  const extraContext = getExtraContext();
  if (!Object.keys(extraContext).length) {
    return record;
  }
  return { ...extraContext, ...record };
}

function emitArchiveResultRecord(status, outputStr, extra = {}) {
  writeFdFully(
    1,
    `${JSON.stringify(
      mergeExtraContext({
        type: "ArchiveResult",
        status,
        output_str: outputStr,
        ...extra,
      })
    )}\n`
  );
}

function emitSnapshotRecord(record) {
  const snapshotRecord = mergeExtraContext({
    type: "Snapshot",
    ...record,
  });
  snapshotRecord.id = snapshotRecord.id ? String(snapshotRecord.id) : "";
  writeFdFully(1, `${JSON.stringify(snapshotRecord)}\n`);
}

// ---------------------------------------------------------------------------
// Atomic file writing
// ---------------------------------------------------------------------------

function writeFileAtomic(filePath, contents) {
  const dir = path.dirname(filePath);
  const base = path.basename(filePath);
  const tmpPath = path.join(dir, `.${base}.${process.pid}.tmp`);
  fs.writeFileSync(tmpPath, contents, "utf8");
  fs.renameSync(tmpPath, filePath);
}

// ---------------------------------------------------------------------------
// Sibling plugin output checking
// ---------------------------------------------------------------------------

function hasStaticFileOutput(staticfileDir = "../staticfile") {
  if (!fs.existsSync(staticfileDir)) return false;
  const stdoutPath = path.join(staticfileDir, "stdout.log");
  if (!fs.existsSync(stdoutPath)) return false;
  const stdout = fs.readFileSync(stdoutPath, "utf8");
  for (const line of stdout.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed.startsWith("{")) continue;
    try {
      const record = JSON.parse(trimmed);
      if (record.type === "ArchiveResult" && record.status === "succeeded") {
        return true;
      }
    } catch (e) {}
  }
  return false;
}

function iterStaticfileTextInputs(snapDir = null) {
  const base = path.resolve(snapDir || process.env.SNAP_DIR || ".");
  const staticfileDir = path.join(base, "staticfile");
  if (!fs.existsSync(staticfileDir)) return [];
  return fs
    .readdirSync(staticfileDir)
    .filter((name) => name.endsWith(".txt"))
    .map((name) => path.join(staticfileDir, name))
    .filter((filePath) => {
      try {
        return fs.statSync(filePath).isFile() && fs.statSync(filePath).size > 0;
      } catch (error) {
        return false;
      }
    })
    .sort();
}

module.exports = {
  PROCESS_EXIT_SKIPPED,
  INTERNAL_INPUT_URL,
  getConfig,
  loadConfig,
  getEnv,
  getEnvBool,
  getEnvInt,
  getEnvArray,
  getExtraContext,
  getSnapDir,
  getCrawlDir,
  getLibDir,
  getPersonasDir,
  getNodeModulesDir,
  getChromeExtensionsDir,
  getMachineType,
  getTestEnv,
  ensureNodeModuleResolution,
  parseArgs,
  emitArchiveResultRecord,
  emitSnapshotRecord,
  writeFileAtomic,
  hasStaticFileOutput,
  iterStaticfileTextInputs,
};

if (require.main === module) {
  const [command] = process.argv.slice(2);
  switch (command) {
    case "getMachineType":
      console.log(getMachineType());
      break;
    case "getLibDir":
      console.log(getLibDir());
      break;
    case "getNodeModulesDir":
      console.log(getNodeModulesDir());
      break;
    case "getChromeExtensionsDir":
    case "getExtensionsDir":
      console.log(getChromeExtensionsDir());
      break;
    case "getTestEnv":
      console.log(JSON.stringify(getTestEnv(), null, 2));
      break;
    default:
      console.error(
        "Usage: utils.js <getMachineType|getLibDir|getNodeModulesDir|getChromeExtensionsDir|getTestEnv>"
      );
      process.exit(1);
  }
}
