import {
  CATALOG_VERSION,
  descriptors,
  GRAPH_ID,
  nodeDefinitions,
  socketTypes,
} from "./catalog.js";

class AuthoringGraphError extends Error {
  constructor(code, details = {}) {
    super(code);
    this.name = "AuthoringGraphError";
    this.code = code;
    this.details = Object.freeze({ ...details });
  }
}

const fail = (code, details) => {
  throw new AuthoringGraphError(code, details);
};
const object = (value) =>
  value !== null && typeof value === "object" && !Array.isArray(value);
const identifier = (value) =>
  typeof value === "string" &&
  /^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
  new TextEncoder().encode(value).length <= 64;
const exactKeys = (value, keys) =>
  object(value) &&
  Object.keys(value).length === keys.length &&
  keys.every((key) => Object.hasOwn(value, key));
const finiteJson = (value) =>
  value === null ||
  typeof value === "string" ||
  typeof value === "boolean" ||
  (typeof value === "number" && Number.isFinite(value)) ||
  (Array.isArray(value) && value.every(finiteJson)) ||
  (object(value) && Object.values(value).every(finiteJson));
const canonical = (value) =>
  Array.isArray(value)
    ? value.map(canonical)
    : object(value)
      ? Object.fromEntries(
          Object.keys(value)
            .sort()
            .map((key) => [key, canonical(value[key])]),
        )
      : value;
const deepFreeze = (value) => {
  if (value && typeof value === "object" && !Object.isFrozen(value)) {
    Object.freeze(value);
    for (const child of Object.values(value)) deepFreeze(child);
  }
  return value;
};
const sourceMaps = new WeakMap();
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
  const details = diagnostic?.details;
  const path = [
    details?.path,
    diagnostic?.path,
    details?.field,
    diagnostic?.field,
  ].find((value) => typeof value === "string");
  const map = sourceMaps.get(ir);
  let match;
  if (path && map)
    for (const key of Object.keys(map))
      if (
        (path === key ||
          path.startsWith(`${key}.`) ||
          path.startsWith(`${key}[`)) &&
        (!match || key.length > match.length)
      )
        match = key;
  return deepFreeze({
    name: diagnostic?.name,
    code: diagnostic?.code,
    message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
    details: details === undefined ? undefined : structuredClone(details),
    path,
    source: match ? structuredClone(map[match]) : undefined,
  });
};

const mapValuePaths = (paths, path, source, value) => {
  paths[path] = source;
  if (Array.isArray(value))
    value.forEach((child, index) =>
      mapValuePaths(paths, `${path}[${index}]`, source, child),
    );
  else if (object(value))
    for (const key of Object.keys(value))
      mapValuePaths(paths, `${path}.${key}`, source, value[key]);
};

function parameterValue(raw, schema, nodeId, key, semanticType) {
  if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type)
    fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
  const value = raw.value;
  const bounded = (number) =>
    Number.isFinite(number) &&
    (schema.minimum === undefined || number >= schema.minimum) &&
    (schema.maximum === undefined || number <= schema.maximum);
  const valid =
    schema.type === "number"
      ? bounded(value) && (!schema.integer || Number.isSafeInteger(value))
      : schema.type === "string"
        ? typeof value === "string" &&
          (!schema.enum || schema.enum.includes(value))
        : schema.type === "boolean"
          ? typeof value === "boolean"
          : schema.type === "vector" || schema.type === "color"
            ? Array.isArray(value) &&
              value.length === (semanticType?.startsWith("vec") ? Number(semanticType.at(-1)) : (schema.type === "vector" ? 3 : 4)) &&
              value.every(bounded)
            : schema.type === "json" && finiteJson(value) && validSemanticValue(value, semanticType);
  if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
  return canonical(structuredClone(raw.value));
}

function validSemanticValue(value, type) {
  if (!type) return true;
  const finiteVector = (candidate, size) =>
    Array.isArray(candidate) && candidate.length === size && candidate.every(Number.isFinite);
  const vector = /^vec([24])$/.exec(type);
  if (vector) return finiteVector(value, Number(vector[1]));
  if (type === "u32x16")
    return Array.isArray(value) && value.length === 16 &&
      value.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffffffff);
  if (type === "local_aabb")
    return exactKeys(value, ["min", "max"]) && finiteVector(value.min, 3) && finiteVector(value.max, 3);
  const match = /^mat([234])$/.exec(type);
  if (match) {
    const size = Number(match[1]);
    return Array.isArray(value) && value.length === size &&
      value.every((column) => finiteVector(column, size));
  }
  return false;
}

export function adaptFxNodeSnapshot(raw, revision = 1) {
  try {
    const rootKeys = [
      "graphId",
      "catalogVersion",
      "nodes",
      "links",
      "metadata",
      "version",
    ];
    if (
      !exactKeys(raw, rootKeys) ||
      !Array.isArray(raw.nodes) ||
      !Array.isArray(raw.links) ||
      !object(raw.metadata) ||
      !finiteJson(raw.metadata)
    )
      fail("AUTHORING_SHAPE");
    if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
      fail("AUTHORING_CATALOG");
    if (!Number.isSafeInteger(raw.version) || raw.version < 0)
      fail("AUTHORING_SHAPE");
    if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
      fail("AUTHORING_REVISION");
    const nodes = new Map(),
      sockets = new Map(),
      paths = {};
    for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
      const n = raw.nodes[ordinal];
      if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
      if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
      const descriptor = descriptors[n.typeId],
        definition = nodeDefinitions[n.typeId];
      if (!descriptor)
        fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
      const nodeKeys = [
        "id",
        "typeId",
        "typeVersion",
        "position",
        "size",
        "label",
        "parameters",
        "sockets",
        "muted",
        "collapsed",
        "extensions",
        "known",
      ];
      if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
      if (
        !exactKeys(n, nodeKeys) ||
        n.known !== true ||
        n.typeVersion !== descriptor.version ||
        typeof n.muted !== "boolean" ||
        typeof n.collapsed !== "boolean" ||
        typeof n.label !== "string" ||
        !exactKeys(n.position, ["x", "y"]) ||
        !Number.isFinite(n.position.x) ||
        !Number.isFinite(n.position.y) ||
        !exactKeys(n.size, ["x", "y"]) ||
        !Number.isFinite(n.size.x) ||
        !Number.isFinite(n.size.y) ||
        n.size.x <= 0 ||
        n.size.y <= 0 ||
        (Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
        !object(n.extensions) ||
        !finiteJson(n.extensions) ||
        !Array.isArray(n.sockets) ||
        !object(n.parameters)
      )
        fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
      const parameterKeys = Object.keys(definition.parameters);
      if (
        Object.keys(n.parameters).length !== parameterKeys.length ||
        !parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
      )
        fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
      const parameters = Object.fromEntries(
        parameterKeys.map((key) => [
          key,
          parameterValue(
            n.parameters[key],
            definition.parameters[key],
            n.id,
            key,
          ),
        ]),
      );
      if (n.typeId === "bloom_blur")
        parameters.direction =
          parameters.direction === "horizontal" ? [1, 0] : [0, 1];
      if (n.typeId === "frustum_cull") {
        parameters.camera = parameters.cameraSelection;
        delete parameters.cameraSelection;
      }
      if (n.typeId === "texture") {
        const extent =
          parameters.extentMode === "absolute"
            ? {
                kind: "absolute",
                width: parameters.absoluteWidth,
                height: parameters.absoluteHeight,
                depthOrArrayLayers: parameters.depthOrArrayLayers,
              }
            : {
                kind: "surface_relative",
                width: {
                  numerator: parameters.relativeWidthNumerator,
                  denominator: parameters.relativeWidthDenominator,
                },
                height: {
                  numerator: parameters.relativeHeightNumerator,
                  denominator: parameters.relativeHeightDenominator,
                },
                depthOrArrayLayers: parameters.depthOrArrayLayers,
              };
        const flat = structuredClone(parameters);
        Object.keys(parameters).forEach((key) => delete parameters[key]);
        Object.assign(parameters, {
          residency: flat.residency,
          texture: {
            dimension: flat.dimension,
            format: flat.format,
            extent,
            mipLevelCount: flat.mipLevelCount,
            sampleCount: Number(flat.sampleCount),
            viewFormats: flat.viewFormat === "none" ? [] : [flat.viewFormat],
          },
        });
      }
      const expected = [
        ...Object.keys(descriptor.inputs),
        ...Object.keys(descriptor.outputs),
      ];
      if (n.sockets.length !== expected.length)
        fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
      for (const s of n.sockets) {
        if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
          fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
        const input = descriptor.inputs[s.key],
          socketDefinition = definition.sockets[s.key],
          direction = input ? "input" : "output",
          dataType = socketDefinition.type,
          socketKeys = [
            "id",
            "key",
            "label",
            "direction",
            "dataType",
            "accepts",
            "maxIncomingLinks",
            ...(socketDefinition.value ? ["defaultValue"] : []),
            "visible",
          ];
        if (
          !exactKeys(s, socketKeys) ||
          s.id !== `${n.id}:${s.key}` ||
          s.label !== socketDefinition.title ||
          s.direction !== direction ||
          s.dataType !== dataType ||
          !Array.isArray(s.accepts) ||
          s.accepts.length !==
            (direction === "input"
              ? socketTypes[dataType].acceptsFrom.length
              : 0) ||
          !s.accepts.every(
            (v, i) =>
              v ===
              (direction === "input"
                ? socketTypes[dataType].acceptsFrom[i]
                : undefined),
          ) ||
          (socketDefinition.value
            ? (() => {
                try {
                  parameterValue(
                    s.defaultValue,
                    socketDefinition.value,
                    n.id,
                    s.key,
                    input.accepted.types[0],
                  );
                  return false;
                } catch {
                  return true;
                }
              })()
            : s.defaultValue !== undefined) ||
          s.visible !== socketDefinition.visible ||
          s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
        )
          fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
        sockets.set(s.id, {
          node: n.id,
          key: s.key,
          direction,
          semanticType: input
            ? input.accepted.types[0]
            : descriptor.outputs[s.key].type,
          authoringType: s.dataType,
          maxIncomingLinks: s.maxIncomingLinks,
          defaultValue: socketDefinition.value
            ? structuredClone(s.defaultValue)
            : undefined,
        });
      }
      if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
        fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
      for (const key of Object.keys(descriptor.inputs)) {
        const authoredDefault = sockets.get(`${n.id}:${key}`).defaultValue;
        if (authoredDefault)
          parameters[`${key}Default`] = parameterValue(
            authoredDefault,
            definition.sockets[key].value,
            n.id,
            key,
            descriptor.inputs[key].accepted.types[0],
          );
      }
      nodes.set(n.id, {
        ordinal,
        value: {
          id: n.id,
          state: n.muted ? "muted" : "enabled",
          executor: { key: n.typeId, version: descriptor.version },
          parameters,
          inputs: {},
        },
      });
    }
    const incoming = new Map(),
      linkIds = new Set(),
      linkSources = new Map();
    for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
      const link = raw.links[ordinal];
      if (
        !object(link) ||
        !identifier(link.id) ||
        linkIds.has(link.id) ||
        !exactKeys(link, [
          "id",
          "fromNodeId",
          "fromSocketId",
          "toNodeId",
          "toSocketId",
          "muted",
          "extensions",
        ]) ||
        typeof link.muted !== "boolean" ||
        !object(link.extensions) ||
        !finiteJson(link.extensions)
      )
        fail("AUTHORING_LINK", { linkId: link?.id });
      linkIds.add(link.id);
      const from = sockets.get(link.fromSocketId),
        to = sockets.get(link.toSocketId);
      if (
        !from ||
        !to ||
        link.fromNodeId !== from.node ||
        link.toNodeId !== to.node ||
        from.direction !== "output" ||
        to.direction !== "input" ||
        (!link.muted &&
          (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
      )
        fail(
          !link.muted &&
            (incoming.get(link.toSocketId) ?? 0) >=
              (to?.maxIncomingLinks ?? Infinity)
            ? "AUTHORING_LINK_INCOMING"
            : "AUTHORING_LINK",
          !link.muted &&
            (incoming.get(link.toSocketId) ?? 0) >=
              (to?.maxIncomingLinks ?? Infinity)
            ? { socketId: link.toSocketId }
            : { linkId: link.id },
        );
      const accepted =
        descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
          .accepted.types;
      const authoringAccepted =
        socketTypes[
          nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key]
            .type
        ].acceptsFrom;
      if (
        !accepted.includes(from.semanticType) ||
        !authoringAccepted.includes(from.authoringType)
      )
        fail("AUTHORING_LINK_TYPE", { linkId: link.id });
      const linkSource = {
        kind: "link",
        linkId: link.id,
        fromNodeId: link.fromNodeId,
        fromSocketId: link.fromSocketId,
        toNodeId: link.toNodeId,
        toSocketId: link.toSocketId,
        muted: link.muted,
        nodeId: to.node,
        input: to.key,
        fromSocket: from.key,
        toSocket: to.key,
      };
      linkSources.set(link.id, linkSource);
      if (!link.muted) {
        incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
        (nodes.get(to.node).value.inputs[to.key] ??= []).push({
          node: from.node,
          socket: from.key,
        });
      }
    }
    const ordered = [...nodes.values()].sort((a, b) =>
      a.value.id < b.value.id
        ? -1
        : a.value.id > b.value.id
          ? 1
          : a.ordinal - b.ordinal,
    );
    for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
      const item = ordered[wireOrdinal];
      item.value.inputs = Object.fromEntries(
        Object.keys(descriptors[item.value.executor.key].inputs)
          .filter((key) => Object.hasOwn(item.value.inputs, key))
          .map((key) => [key, item.value.inputs[key]]),
      );
      const base = `nodes[${wireOrdinal}]`;
      const nodeSource = { kind: "node", nodeId: item.value.id };
      paths[base] = nodeSource;
      for (const field of [
        "id",
        "state",
        "executor",
        "executor.key",
        "executor.version",
      ])
        paths[`${base}.${field}`] = nodeSource;
      paths[`${base}.parameters`] = nodeSource;
      const parameterSource = (parameter) => ({
        kind: "parameter",
        nodeId: item.value.id,
        parameter,
      });
      if (item.value.executor.key === "texture") {
        const root = `${base}.parameters`;
        const texture = item.value.parameters.texture;
        paths[`${root}.residency`] = parameterSource("residency");
        paths[`${root}.texture`] = nodeSource;
        paths[`${root}.texture.dimension`] = parameterSource("dimension");
        paths[`${root}.texture.format`] = parameterSource("format");
        paths[`${root}.texture.extent`] = parameterSource("extentMode");
        paths[`${root}.texture.extent.kind`] = parameterSource("extentMode");
        paths[`${root}.texture.extent.depthOrArrayLayers`] =
          parameterSource("depthOrArrayLayers");
        if (texture.extent.kind === "absolute") {
          paths[`${root}.texture.extent.width`] =
            parameterSource("absoluteWidth");
          paths[`${root}.texture.extent.height`] =
            parameterSource("absoluteHeight");
        } else {
          paths[`${root}.texture.extent.width`] = parameterSource("extentMode");
          paths[`${root}.texture.extent.width.numerator`] = parameterSource(
            "relativeWidthNumerator",
          );
          paths[`${root}.texture.extent.width.denominator`] = parameterSource(
            "relativeWidthDenominator",
          );
          paths[`${root}.texture.extent.height`] =
            parameterSource("extentMode");
          paths[`${root}.texture.extent.height.numerator`] = parameterSource(
            "relativeHeightNumerator",
          );
          paths[`${root}.texture.extent.height.denominator`] = parameterSource(
            "relativeHeightDenominator",
          );
        }
        paths[`${root}.texture.mipLevelCount`] =
          parameterSource("mipLevelCount");
        paths[`${root}.texture.sampleCount`] = parameterSource("sampleCount");
        mapValuePaths(
          paths,
          `${root}.texture.viewFormats`,
          parameterSource("viewFormat"),
          texture.viewFormats,
        );
      } else
        for (const key of Object.keys(item.value.parameters))
          mapValuePaths(
            paths,
            `${base}.parameters.${key}`,
            key.endsWith("Default") && Object.hasOwn(descriptors[item.value.executor.key].inputs, key.slice(0, -7))
              ? {
                  kind: "input",
                  nodeId: item.value.id,
                  input: key.slice(0, -7),
                  socketId: `${item.value.id}:${key.slice(0, -7)}`,
                  unconnected: true,
                }
              : parameterSource(
                  item.value.executor.key === "frustum_cull" && key === "camera"
                    ? "cameraSelection"
                    : key,
                ),
            item.value.parameters[key],
          );
      for (const key of Object.keys(
        descriptors[item.value.executor.key].inputs,
      )) {
        const links = raw.links.filter(
          (x) =>
            !x.muted &&
            x.toNodeId === item.value.id &&
            sockets.get(x.toSocketId)?.key === key,
        );
        const source = linkSources.get(links[0]?.id) ?? {
          kind: "input",
          nodeId: item.value.id,
          input: key,
          socketId: `${item.value.id}:${key}`,
          unconnected: true,
        };
        paths[`${base}.inputs.${key}`] = source;
        for (const [index, link] of links.entries()) {
          const linkSource = linkSources.get(link.id);
          paths[`${base}.inputs.${key}[${index}]`] = linkSource;
          paths[`${base}.inputs.${key}[${index}].node`] = linkSource;
          paths[`${base}.inputs.${key}[${index}].socket`] = {
            kind: "socket",
            nodeId: link.fromNodeId,
            socketId: link.fromSocketId,
            socket: sockets.get(link.fromSocketId).key,
            linkId: link.id,
          };
        }
      }
      paths[`${base}.inputs`] = nodeSource;
    }
    const ir = {
      schemaVersion: 3,
      graphId: GRAPH_ID,
      revision,
      nodes: ordered.map((item) => item.value),
    };
    const graphSource = { kind: "graph", graphId: GRAPH_ID };
    for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
      paths[field] = graphSource;
    deepFreeze(paths);
    deepFreeze(ir);
    sourceMaps.set(ir, paths);
    return ir;
  } catch (error) {
    if (error instanceof AuthoringGraphError) throw error;
    fail("AUTHORING_SHAPE");
  }
}
