#!/usr/bin/env node
import { program } from 'commander';
import { WebSocketServer } from 'ws';
import { logger, forklift, wsSource } from '../dist/src/index.js';
import { readFile } from 'node:fs/promises';
import { parse } from 'yaml';
import { getDynamicBuilder, getLookupFn } from '@polkadot-api/metadata-builders';
import { unifyMetadata, decAnyMetadata } from '@polkadot-api/substrate-bindings';
import { createClient } from '@polkadot-api/substrate-client';
import { Enum, Binary } from 'polkadot-api';
import { getWsRawProvider, createWsClient } from 'polkadot-api/ws';

const createWsServer = async (forklift, options) => {
  let port = options?.port ? Number(options.port) : 9944;
  while (true) {
    const wsServer = new WebSocketServer({ port });
    wsServer.on("connection", (ws) => {
      const connection = forklift.serve((msg) => ws.send(JSON.stringify(msg)));
      ws.on("message", async (message) => {
        try {
          connection.send(JSON.parse(String(message)));
        } catch {
          ws.send(
            JSON.stringify({
              jsonrpc: "2.0",
              error: {
                code: -32700,
                message: "Unable to parse message"
              }
            })
          );
        }
      });
      ws.on("close", () => {
        connection.disconnect();
      });
    });
    try {
      await new Promise((resolve, reject) => {
        const onError = (error) => {
          wsServer.off("listening", onListening);
          reject(error);
        };
        const onListening = () => {
          wsServer.off("error", onError);
          resolve();
        };
        wsServer.once("error", onError);
        wsServer.once("listening", onListening);
      });
      const address = wsServer.address();
      return {
        port: typeof address === "object" && address ? address.port : port
      };
    } catch (ex) {
      wsServer.close();
      if (ex?.code === "EADDRINUSE" && typeof port === "number") {
        port++;
        continue;
      }
      throw ex;
    }
  }
};

function parseStorageEntry(entry, idx) {
  if (typeof entry !== "object" || entry === null) {
    throw new Error(`storage[${idx}]: must be an object`);
  }
  if ("pallet" in entry && "entry" in entry) {
    const e = entry;
    if (typeof e.pallet !== "string")
      throw new Error(`storage[${idx}].pallet must be a string`);
    if (typeof e.entry !== "string")
      throw new Error(`storage[${idx}].entry must be a string`);
    if (!Array.isArray(e.key))
      throw new Error(`storage[${idx}].key must be an array`);
    return {
      pallet: e.pallet,
      entry: e.entry,
      key: e.key,
      value: JSON.parse(
        JSON.stringify(e.value),
        (_, value) => parseNumeric(value) ?? value
      ) ?? null
    };
  } else if ("key" in entry) {
    const e = entry;
    if (typeof e.key !== "string" || !e.key.startsWith("0x")) {
      throw new Error(`storage[${idx}].key must be a 0x-prefixed hex string`);
    }
    if (e.value !== null && e.value !== void 0 && (typeof e.value !== "string" || !e.value.startsWith("0x"))) {
      throw new Error(
        `storage[${idx}].value must be a 0x-prefixed hex string or null`
      );
    }
    return {
      key: e.key,
      value: e.value ?? null
    };
  }
  throw new Error(
    `storage[${idx}]: must have either "key" (raw hex) or "pallet"+"storage" (decoded)`
  );
}
function validateDelayMode(raw, field) {
  if (raw === "manual") return "manual";
  if (typeof raw === "object" && raw !== null && "timer" in raw && typeof raw.timer === "number") {
    return raw;
  }
  throw new Error(
    `${field}: expected "manual" or { timer: <ms> }, got ${JSON.stringify(raw)}`
  );
}
function parseChainConfig(raw, name) {
  if (typeof raw !== "object" || raw === null)
    throw new Error(`Chain "${name}": must be an object`);
  const r = raw;
  if (r.endpoint === void 0)
    throw new Error(`Chain "${name}": missing "endpoint"`);
  if (typeof r.endpoint !== "string" && !(Array.isArray(r.endpoint) && r.endpoint.every((e) => typeof e === "string"))) {
    throw new Error(
      `Chain "${name}": "endpoint" must be a string or array of strings`
    );
  }
  if (r.block !== void 0 && typeof r.block !== "number" && typeof r.block !== "string") {
    throw new Error(`Chain "${name}": "block" must be a number or hex string`);
  }
  const parsedPort = r.port == null ? null : parseNumber(r.port);
  if (r.port != void 0 && (parsedPort == null || parsedPort < 1 || parsedPort > 65535)) {
    throw new Error(
      `Chain "${name}": "port" must be an integer between 1 and 65535`
    );
  }
  const options = {};
  if (r.options !== void 0) {
    if (typeof r.options !== "object" || r.options === null)
      throw new Error(`Chain "${name}": "options" must be an object`);
    const o = r.options;
    if (o.disableOnIdle !== void 0)
      options.disableOnIdle = Boolean(o.disableOnIdle);
    if (o.buildBlockMode !== void 0)
      options.buildBlockMode = validateDelayMode(
        o.buildBlockMode,
        `${name}.options.buildBlockMode`
      );
    if (o.finalizeMode !== void 0)
      options.finalizeMode = validateDelayMode(
        o.finalizeMode,
        `${name}.options.finalizeMode`
      );
  }
  const storage = [];
  if (r.storage !== void 0) {
    if (!Array.isArray(r.storage))
      throw new Error(`Chain "${name}": "storage" must be an array`);
    for (let i = 0; i < r.storage.length; i++)
      storage.push(parseStorageEntry(r.storage[i], i));
  }
  if (r.parachainOf !== void 0 && typeof r.parachainOf !== "string")
    throw new Error(`Chain "${name}": "parachainOf" must be a string`);
  return {
    endpoint: r.endpoint,
    block: parseNumber(r.block) ?? r.block,
    port: parsedPort ?? void 0,
    ...r.parachainOf !== void 0 && {
      parachainOf: r.parachainOf
    },
    ...Object.keys(options).length > 0 && { options },
    ...storage.length > 0 && { storage }
  };
}
function parseConfig(yaml) {
  let raw;
  try {
    raw = parse(yaml);
  } catch (e) {
    throw new Error(`Failed to parse YAML: ${e.message}`);
  }
  if (typeof raw !== "object" || raw === null)
    throw new Error("Config must be a YAML object");
  const r = raw;
  if ("chains" in r) {
    if (typeof r.chains !== "object" || r.chains === null || Array.isArray(r.chains)) {
      throw new Error('"chains" must be a map of chain name \u2192 chain config');
    }
    const chains = {};
    for (const [name, chainRaw] of Object.entries(r.chains)) {
      chains[name] = parseChainConfig(chainRaw, name);
    }
    for (const [name, chain] of Object.entries(chains)) {
      if (chain.parachainOf !== void 0 && !(chain.parachainOf in chains))
        throw new Error(
          `Chain "${name}": parachainOf "${chain.parachainOf}" not found in chains`
        );
    }
    return { type: "multi", chains };
  }
  return { type: "single", chain: parseChainConfig(raw, "root") };
}
async function loadConfig(path) {
  let text;
  try {
    text = await readFile(path, "utf8");
  } catch {
    throw new Error(`Cannot read config file: ${path}`);
  }
  return parseConfig(text);
}
const parseNumber = (value) => {
  if (typeof value === "number") return value;
  if (typeof value !== "string") return null;
  const valueStr = value.replaceAll("_", "");
  return valueStr.startsWith("0x") || Number.isNaN(valueStr) ? null : Number(valueStr);
};
const parseNumeric = (value) => {
  if (typeof value === "number") return value;
  if (typeof value !== "string") return null;
  if (value.endsWith("n")) {
    try {
      return BigInt(value.replaceAll("_", "").slice(0, -1));
    } catch {
      return null;
    }
  }
  return parseNumber(value);
};

const log = logger.child({ module: "cli" });

const runFromConfig = async (config) => {
  const chains = config.type === "single" ? {
    single: config.chain
  } : config.chains;
  const ports = await Promise.all(
    Object.entries(chains).map(
      ([key, chain]) => startChain(chain, config.type === "single" ? void 0 : key)
    )
  );
  if (config.type === "multi") {
    const chainToPort = Object.fromEntries(ports);
    const parasByRelay = /* @__PURE__ */ new Map();
    for (const [name, chain] of Object.entries(config.chains)) {
      const relay = chain.parachainOf;
      if (!relay) continue;
      const list = parasByRelay.get(relay) ?? [];
      list.push(name);
      parasByRelay.set(relay, list);
    }
    for (const [name, chain] of Object.entries(config.chains)) {
      const relay = chain.parachainOf;
      if (!relay) continue;
      log.info(`Attaching ${name} as parachain to ${relay}`);
      const client = createClient(
        getWsRawProvider(`ws://localhost:${chainToPort[name]}`)
      );
      await rawClientRequest(client, "forklift_xcm_attach_relay", [
        `ws://localhost:${chainToPort[relay]}`
      ]);
      client.destroy();
    }
    for (const paras of parasByRelay.values()) {
      for (const a of paras) {
        for (const b of paras) {
          if (b <= a) continue;
          log.info(`Attaching ${a} and ${b} as siblings`);
          const client = createClient(
            getWsRawProvider(`ws://localhost:${chainToPort[a]}`)
          );
          await rawClientRequest(client, "forklift_xcm_attach_sibling", [
            `ws://localhost:${chainToPort[b]}`
          ]);
          client.destroy();
        }
      }
    }
  }
};
const rawClientRequest = (client, method, params) => new Promise(
  (resolve, reject) => client._request(method, params, {
    onSuccess: resolve,
    onError: reject
  })
);
const startChain = async (config, key) => {
  const logWithKey = log ? log.child({ chain: key }) : log;
  logWithKey.info(
    `Forking ${config.endpoint}${config.block !== void 0 ? ` at block ${config.block}` : ""}`
  );
  const f = forklift(
    wsSource(config.endpoint, {
      atBlock: config.block
    }),
    {
      buildBlockMode: config.options?.buildBlockMode && (typeof config.options.buildBlockMode === "string" ? Enum("manual") : Enum("timer", config.options.buildBlockMode.timer)),
      finalizeMode: config.options?.finalizeMode && (typeof config.options.finalizeMode === "string" ? Enum("manual") : Enum("timer", config.options.finalizeMode.timer)),
      disableOnIdle: config.options?.disableOnIdle,
      mockSignatureHost: config.options?.mockSignatureHost
    }
  );
  const server = await createWsServer(f, { port: config.port });
  if (config.storage) {
    logWithKey.info(`Waiting for initial block`);
    const client = createWsClient(`ws://localhost:${server.port}`);
    const finalized = await client.getFinalizedBlock();
    logWithKey.info(`Overriding storage`);
    const metadata = await client.getMetadata(finalized.hash);
    const dynamicBuilder = getDynamicBuilder(
      getLookupFn(unifyMetadata(decAnyMetadata(metadata)))
    );
    const rawOverrides = await Promise.all(
      config.storage.map(
        async (override) => {
          if ("pallet" in override) {
            const codecs = dynamicBuilder.buildStorage(
              override.pallet,
              override.entry
            );
            if (!codecs) {
              logWithKey.error(
                `Storage entry ${override.pallet}.${override.entry} not found, skipping`
              );
              return null;
            }
            let key2 = "";
            let value = null;
            try {
              key2 = codecs.keys.enc(...override.key ?? []);
            } catch {
              logWithKey.error(
                `Storage key ${override.pallet}.${override.entry} not compatible, skipping`
              );
              return null;
            }
            try {
              value = override.value == null ? null : Binary.toHex(codecs.value.enc(override.value));
            } catch {
              logWithKey.error(
                `Storage value ${override.pallet}.${override.entry} not compatible, skipping`
              );
              return null;
            }
            return {
              key: key2,
              value
            };
          } else {
            return override;
          }
        }
      )
    );
    await client._request("dev_setStorage", [
      rawOverrides.filter((v) => v != null).map((override) => [override.key, override.value])
    ]);
    client.destroy();
  }
  logWithKey.info(
    `${key ? key + " " : ""}listening on ws://localhost:${server.port} https://dev.papi.how/storage#networkId=localhost&endpoint=ws%3A%2F%2F127.0.0.1%3A${server.port}`
  );
  return [key, server.port];
};

program.name("forklift").argument("[url]", "WebSocket URL of the node to fork").option("-c --config <file>", "use YAML config file").option("-b, --block <block>", "block number or hash to fork from").option("-p, --port <port>", "port to listen on", "3000").option(
  "-l, --log-level <level>",
  "log level (trace|debug|info|warn|error|fatal)",
  "info"
).action(
  async (url, opts) => {
    logger.level = opts.logLevel;
    const config = opts.config ? await loadConfig(opts.config) : null;
    if (config) {
      return runFromConfig(config);
    }
    if (!url) {
      program.error("error: either <url> or --config <file> is required");
    }
    const atBlock = opts.block ? /^\d+$/.test(opts.block) ? parseInt(opts.block, 10) : opts.block : void 0;
    const port = parseInt(opts.port, 10);
    const f = forklift(wsSource(url, { atBlock }));
    const server = await createWsServer(f, { port });
    log.info(
      `Forking ${url}${atBlock !== void 0 ? ` at block ${atBlock}` : ""}`
    );
    log.info(`Listening on ws://localhost:${server.port}`);
  }
);
program.parse();
//# sourceMappingURL=cli.js.map
