// Generates a compact JSON schema model from the RA3 Mod SDK XSD files.
//
// Usage:
//   node tools/xsd-to-model.mjs [path-to-CnC3Types.xsd] [output.json]
//
// The model is used at runtime by the extension for completions, hover
// documentation and diagnostics. It is regenerated by developers, not by
// end users.

import { XMLParser } from "fast-xml-parser";
import {
  readFileSync,
  writeFileSync,
  mkdirSync,
  existsSync,
  statSync,
} from "node:fs";
import { dirname, resolve, basename, relative } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT =
  process.env.RA3_SDK_XSD ??
  "C:\\Apps\\RA3-MODSDK-X\\Schemas\\xsd\\CnC3Types.xsd";
const rootXsdPath = resolve(process.argv[2] ?? DEFAULT_ROOT);
const outPath = resolve(
  process.argv[3] ?? resolve(__dirname, "..", "src", "model", "schema-model.json"),
);

if (!existsSync(rootXsdPath)) {
  console.error(`Root XSD not found: ${rootXsdPath}`);
  console.error("Pass a path or set RA3_SDK_XSD.");
  process.exit(1);
}

// ── XML parsing ──────────────────────────────────────────────────────
const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: "@_",
  removeNSPrefix: true,
  parseTagValue: false,
  parseAttributeValue: false,
  trimValues: true,
  ignoreDeclaration: true,
});

const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);

// ── Registry ─────────────────────────────────────────────────────────
const loadedFiles = new Map(); // absolute path -> parsed document
const complexTypes = new Map(); // name -> raw node
const simpleTypes = new Map(); // name -> raw node
const globalElements = new Map(); // name -> {type, doc}

function loadXsd(absPath, seen = new Set()) {
  const key = absPath.toLowerCase();
  if (loadedFiles.has(key)) return loadedFiles.get(key);
  if (seen.has(key)) return null; // include cycle
  seen.add(key);

  let text;
  try {
    text = readFileSync(absPath, "utf8");
  } catch (err) {
    console.warn(`  ! cannot read ${absPath}: ${err.message}`);
    return null;
  }
  const doc = parser.parse(text);
  loadedFiles.set(key, doc);

  const schema = doc?.schema ?? doc?.["xs:schema"] ?? {};
  for (const inc of asArray(schema.include)) {
    const loc = inc?.["@_schemaLocation"];
    if (!loc) continue;
    loadXsd(resolve(dirname(absPath), loc), seen);
  }

  for (const ct of asArray(schema.complexType)) {
    const name = ct?.["@_name"];
    if (name) complexTypes.set(name, ct);
  }
  for (const st of asArray(schema.simpleType)) {
    const name = st?.["@_name"];
    if (name) simpleTypes.set(name, st);
  }
  for (const el of asArray(schema.element)) {
    const name = el?.["@_name"];
    if (!name) continue;
    if (el.complexType) {
      // Inline complexType for a global element (e.g. AssetDeclaration).
      const keyName = `@global:${name}`;
      complexTypes.set(keyName, el.complexType);
      globalElements.set(name, { type: keyName, doc: docOf(el) });
    } else if (el["@_type"]) {
      globalElements.set(name, { type: el["@_type"], doc: docOf(el) });
    }
  }
  return doc;
}

function docOf(node) {
  const doc = node?.annotation?.documentation;
  if (doc == null) return "";
  return String(doc).trim();
}

// ── Attribute / simple type helpers ──────────────────────────────────

function normalizeTypeName(raw) {
  if (!raw) return null;
  return String(raw).replace(/^xs:/, "").trim();
}

const BUILTIN = new Set([
  "string",
  "boolean",
  "integer",
  "int",
  "long",
  "short",
  "byte",
  "unsignedInt",
  "unsignedShort",
  "unsignedByte",
  "decimal",
  "float",
  "double",
  "anyURI",
  "date",
  "dateTime",
  "duration",
  "token",
  "NMTOKEN",
  "Name",
  "ID",
]);

function patternAllowsDefine(pattern) {
  return /\(=\[_a-zA-Z]/.test(pattern) || /=\$/.test(pattern);
}

function enumOfSimpleType(node) {
  const restriction = node?.restriction;
  if (!restriction) return [];
  const enums = asArray(restriction.enumeration).map((e) => e?.["@_value"]);
  return enums.filter((v) => v != null);
}

function patternOfSimpleType(node) {
  const restriction = node?.restriction;
  if (!restriction) return null;
  const pats = asArray(restriction.pattern).map((p) => p?.["@_value"]);
  return pats.length ? pats : null;
}

/**
 * Resolve an attribute type reference to a normalized descriptor.
 * Returns { kind: "builtin"|"simple"|"complex", name, refType, enumValues,
 *           allowsDefine, isBoolean, base, doc }
 */
function resolveTypeDescriptor(typeName, memo = new Map()) {
  const name = normalizeTypeName(typeName);
  if (name == null) return null;
  if (memo.has(name)) return memo.get(name);
  if (BUILTIN.has(name)) {
    const desc = {
      kind: "builtin",
      name,
      refType: null,
      enumValues: [],
      isList: false,
      allowsDefine: false,
      isBoolean: name === "boolean",
      base: null,
      doc: "",
    };
    memo.set(name, desc);
    return desc;
  }

  const st = simpleTypes.get(name);
  if (st) {
    memo.set(name, null); // guard against cycles
    const listNode = st?.list;
    if (listNode) {
      // xs:list types (bit flags such as LocomotorSurfaceBitFlags, AssetIdList,
      // PercentageList, ...) serialize as whitespace-separated items. The
      // usable enum/ref semantics come from the item type, so inherit them.
      let itemDesc = null;
      let inlineEnum = [];
      let inlineAllowsDefine = false;
      const itemTypeName = normalizeTypeName(listNode?.["@_itemType"]);
      if (itemTypeName) {
        itemDesc = resolveTypeDescriptor(itemTypeName, memo);
      } else if (listNode.simpleType) {
        inlineEnum = enumOfSimpleType(listNode.simpleType);
        const pats = patternOfSimpleType(listNode.simpleType);
        inlineAllowsDefine = pats ? pats.some(patternAllowsDefine) : false;
      }
      const desc = {
        kind: "simple",
        name,
        refType: itemDesc?.refType ?? null,
        enumValues: itemDesc?.enumValues?.length ? itemDesc.enumValues : inlineEnum,
        isList: true,
        allowsDefine: (itemDesc?.allowsDefine ?? false) || inlineAllowsDefine,
        isRef: itemDesc?.isRef ?? false,
        isBoolean: false,
        base: itemDesc?.base ?? null,
        doc: docOf(st),
      };
      memo.set(name, desc);
      return desc;
    }
    const base = normalizeTypeName(st?.restriction?.["@_base"]);
    const baseDesc = base ? resolveTypeDescriptor(base, memo) : null;
    const enumValues = enumOfSimpleType(st);
    const patterns = patternOfSimpleType(st);
    const refType =
      st?.["@_refType"] ??
      (baseDesc?.refType ?? null);
    const desc = {
      kind: "simple",
      name,
      refType: normalizeTypeName(refType) || baseDesc?.refType || null,
      enumValues: enumValues.length ? enumValues : baseDesc?.enumValues ?? [],
      isList: false,
      allowsDefine:
        (patterns ? patterns.some(patternAllowsDefine) : false) ||
        baseDesc?.allowsDefine ||
        false,
      isRef:
        st?.["@_isRef"] === "true" ||
        st?.["@_isWeakRef"] === "true" ||
        baseDesc?.isRef ||
        false,
      isBoolean: name === "SageBool" || baseDesc?.isBoolean || false,
      base,
      doc: docOf(st),
    };
    memo.set(name, desc);
    return desc;
  }

  if (complexTypes.has(name) || name.startsWith("@")) {
    const desc = {
      kind: "complex",
      name,
      refType: null,
      enumValues: [],
      isList: false,
      allowsDefine: false,
      isRef: false,
      isBoolean: false,
      base: null,
      doc: "",
    };
    memo.set(name, desc);
    return desc;
  }

  const desc = {
    kind: "unknown",
    name,
    refType: null,
    enumValues: [],
    isList: false,
    allowsDefine: false,
    isRef: false,
    isBoolean: false,
    base: null,
    doc: "",
  };
  memo.set(name, desc);
  return desc;
}

// ── Complex type expansion ───────────────────────────────────────────

function collectChildren(node, out = []) {
  if (!node || typeof node !== "object") return out;
  for (const el of asArray(node.element)) {
    const name = el?.["@_name"];
    if (!name) continue;
    const child = {
      name,
      type: normalizeTypeName(el["@_type"]),
      min: parseInt(el["@_minOccurs"] ?? "1", 10),
      max: el["@_maxOccurs"] === "unbounded" ? -1 : parseInt(el["@_maxOccurs"] ?? "1", 10),
      doc: docOf(el),
    };
    if (el.complexType) {
      const inlineKey = `@inline:${name}`;
      if (!complexTypes.has(inlineKey)) complexTypes.set(inlineKey, el.complexType);
      child.type = inlineKey;
    }
    out.push(child);
  }
  for (const key of ["sequence", "choice", "all"]) {
    for (const container of asArray(node[key])) {
      collectChildren(container, out);
    }
  }
  return out;
}

function collectAttributes(node, out = []) {
  if (!node) return out;
  for (const attr of asArray(node.attribute)) {
    const name = attr?.["@_name"];
    if (!name) continue;
    const use = attr?.["@_use"];
    let desc = null;
    let inlineEnum = [];
    if (attr["@_type"]) {
      desc = resolveTypeDescriptor(attr["@_type"]);
    } else if (attr.simpleType) {
      inlineEnum = enumOfSimpleType(attr.simpleType);
      const pats = patternOfSimpleType(attr.simpleType);
      desc = {
        kind: "simple",
        name: `@attr:${name}`,
        refType: attr.simpleType?.["@_refType"] ?? null,
        enumValues: inlineEnum,
        isList: false,
        allowsDefine: pats ? pats.some(patternAllowsDefine) : false,
        isBoolean: false,
        base: normalizeTypeName(attr.simpleType?.restriction?.["@_base"]),
        doc: docOf(attr.simpleType),
      };
    }
    const entry = {
      name,
      required: use === "required",
      default: attr["@_Default"] ?? attr["@_default"] ?? null,
      doc: docOf(attr),
      kind: desc?.kind ?? "unknown",
      type: desc?.name ?? null,
      // xas:refType may be declared on the attribute itself (e.g.
      // <xs:attribute name="id" type="Poid" xas:refType="ModuleData" />) or
      // on its simple type. The attribute-level declaration wins.
      refType:
        normalizeTypeName(attr["@_refType"]) ??
        normalizeTypeName(desc?.refType) ??
        null,
      enumValues: desc?.enumValues ?? inlineEnum ?? [],
      isList: desc?.isList ?? false,
      allowsDefine: desc?.allowsDefine ?? false,
      isRef: desc?.isRef ?? false,
      isBoolean: desc?.isBoolean ?? false,
      base: normalizeTypeName(desc?.base) ?? null,
    };
    out.push(entry);
  }
  return out;
}

const expandedTypes = new Map(); // typeName -> {children, attributes, base, doc}

function expandComplexType(name, chain = []) {
  if (expandedTypes.has(name)) return expandedTypes.get(name);
  if (chain.includes(name)) {
    // inheritance cycle (should not happen) - bail out
    const empty = { children: [], attributes: [], base: null, doc: "" };
    expandedTypes.set(name, empty);
    return empty;
  }
  const node = complexTypes.get(name);
  if (!node) return null;

  const extension = node?.complexContent?.extension;
  const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
  const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;

  const ownChildren = collectChildren(extension ?? node);
  const ownAttributes = collectAttributes(extension ?? node);

  const children = [...(base?.children ?? []), ...ownChildren];
  const attributes = [...(base?.attributes ?? [])];
  for (const attr of ownAttributes) {
    const idx = attributes.findIndex((a) => a.name === attr.name);
    if (idx >= 0) attributes[idx] = attr;
    else attributes.push(attr);
  }

  const result = {
    children,
    attributes,
    base: baseName,
    doc: docOf(node),
  };
  expandedTypes.set(name, result);
  return result;
}

// ── Build model ──────────────────────────────────────────────────────

console.log(`Loading XSD tree from ${rootXsdPath} ...`);
const start = Date.now();
loadXsd(rootXsdPath);
console.log(
  `Loaded ${loadedFiles.size} XSD files in ${((Date.now() - start) / 1000).toFixed(1)}s` +
    ` (${complexTypes.size} complexTypes, ${simpleTypes.size} simpleTypes, ${globalElements.size} global elements)`,
);

// Top-level elements come from the AssetDeclaration inline choice plus any
// global elements registered directly.
const assetDecl = globalElements.get("AssetDeclaration");
const topLevelElements = new Set();
if (assetDecl) {
  const declType = expandComplexType(assetDecl.type);
  for (const child of declType?.children ?? []) {
    topLevelElements.add(child.name);
  }
}

// Serialize resolved complex types.
const typesOut = {};
for (const name of complexTypes.keys()) {
  const resolved = expandComplexType(name);
  if (resolved) {
    typesOut[name] = {
      kind: "complex",
      ...resolved,
    };
  }
}
for (const [name, node] of simpleTypes) {
  const desc = resolveTypeDescriptor(name);
  typesOut[name] = {
    kind: "simple",
    base: desc?.base ?? normalizeTypeName(node?.restriction?.["@_base"]) ?? null,
    refType: desc?.refType ?? node?.["@_refType"] ?? null,
    isRef: node?.["@_isRef"] === "true" || desc?.refType != null,
    enumValues: desc?.enumValues ?? [],
    isList: desc?.isList ?? false,
    allowsDefine: desc?.allowsDefine ?? false,
    doc: docOf(node),
  };
}

// Inheritance map: subtype -> [base names], used to match refType against
// concrete asset types (a ref to BaseAudioEventInfo should also match
// AudioEvent).
const subTypesOf = {};
for (const [name, node] of complexTypes) {
  const base = normalizeTypeName(node?.complexContent?.extension?.["@_base"]);
  if (base) {
    (subTypesOf[name] ??= []).push(base);
  }
}

const model = {
  version: 2,
  rootXsd: basename(rootXsdPath),
  targetNamespace: "uri:ea.com:eala:asset",
  topLevelElements: [...topLevelElements].sort(),
  elements: Object.fromEntries(
    [...globalElements.entries()].map(([name, info]) => [
      name,
      { type: normalizeTypeName(info.type), doc: info.doc },
    ]),
  ),
  types: typesOut,
  subTypesOf,
};

mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(model));
const sizeKb = Math.round(statSync(outPath).size / 1024);
console.log(
  `Wrote ${outPath} (${sizeKb} KB, ${topLevelElements.size} top-level elements, ` +
    `${Object.keys(typesOut).length} types)`,
);
