data_loaders.js

import { hasColonOutsideParens, resolve, splitAfter, splitOn } from "./string_utils.js";
import { LocationTemplate } from "./models/location.js";
import { parseDelimiters } from "./string_utils.js";
import RarityTable from "./tables/rarity_table.js";
import Table from "./tables/table.js";

const WS = /^\s*$/;
// Can convert "Room" or "Room (abstract)" into parts 1 and 2 w/ no parentheses
const LIST_PROP_FIELDS = ["inventory", "traits"];
const STRING_PROP_FIELDS = ["series", "owner", "image"];
const VARIABLES = new Map();
const LOCATIONS = [];
const NEW_CONFIG_REGEX =
  /^(\S+(?:\s+(?![("#])\S+)*)(?:\s+"([^"]+)")?(?:\s+\(([^)]+)\))?(?:\s+#(.+))?$/;
// Regex to match 'Type as "Label"' syntax - captures the type and the label
const AS_SYNTAX_REGEX = /^(.+?)\s+as\s+"([^"]+)"$/;

// Helper function to load file content - works in both Node.js and browser
async function loadFileContent(fileName) {
  const isNode = typeof process !== "undefined" && process.versions?.node;
  const filePath = `/src/data/${fileName}`;
  if (isNode) {
    // Use Node.js fs module for file loading
    const { readFile } = await import("node:fs/promises");
    const { fileURLToPath } = await import("node:url");
    const { dirname, join } = await import("node:path");
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);
    const fullPath = join(__dirname, "data", fileName);
    return await readFile(fullPath, "utf-8");
  } else {
    // Use fetch for browser
    const response = await fetch(filePath);
    return await response.text();
  }
}

export async function /* String[] */ lineLoader(fileName) {
  const STARTS_WS = /^\s/;
  const result = await loadFileContent(fileName);
  return result.split("\n").reduce((acc, line) => {
    if (STARTS_WS.test(line)) {
      acc[acc.length - 1] += line;
    } else {
      acc.push(line);
    }
    return acc;
  }, []);
}

export async function /* String[] */ indentLoader(fileName) {
  const result = await loadFileContent(fileName);
  return result.split("\n");
}

export async function /* Map<String, String[]> */ mapLoader(fileName, separator = ",") {
  const STARTS_WS = /^\s/;
  const WS = /^\s*$/;
  const result = await loadFileContent(fileName);
  let lastKey = null;
  return result.split("\n").reduce((map, line) => {
    if (STARTS_WS.test(line)) {
      const array = map.get(lastKey);
      const values = line
        .split(separator)
        .map((v) => v.trim())
        .filter((v) => v.length > 0);
      map.set(lastKey, array.concat(values));
    } else if (WS.test(line) || line.startsWith("//")) {
      // skip blank lines
    } else {
      const [key, valueString] = line.split("=");
      const values = valueString
        .split(separator)
        .map((v) => v.trim())
        .filter((v) => v.length > 0);
      map.set(key.trim(), values);
      lastKey = key;
    }
    return map;
  }, new Map());
}

export function rarityTable(array) {
  const table = new RarityTable({ useStrict: true, outFunction: (f) => resolve(f) });
  for (let i = 0, len = array.length; i < len; i++) {
    const [frequency, value] = array[i].split("~");
    table.add(frequency.trim(), value.trim());
  }
  return table;
}

export function percentTable(array) {
  const table = new Table({ useStrict: true, outFunction: (f) => resolve(f) });
  for (let i = 0, len = array.length; i < len; i++) {
    const [percent, value] = array[i].split("%");
    table.add(parseInt(percent.trim()), value.trim());
  }
  return table;
}

// LOCATION LOADER

function flush(props) {
  if (props.type.startsWith("$")) {
    VARIABLES.set(props.type.trim(), props);
  } else {
    const template = new LocationTemplate(props);
    LOCATIONS.push(template);
  }
}

function parens(el) {
  if (!el.includes("Bag") && !el.includes("Stock")) {
    const [hasParens, value, parensValue] = parseDelimiters(el);
    if (hasParens) {
      return parensValue + ":" + value;
    }
  }
  return el;
}

/**
 * Parse an element that may contain 'as' syntax (e.g., 'Living Room as "Parlor"').
 * Returns an object with the type and optional alias.
 * @param {string} element - The element string to parse
 * @returns {{type: string, alias: string|null}} - The parsed type and alias
 */
function captureInlineSequence(aliases, element) {
  const match = element.match(AS_SYNTAX_REGEX);
  if (match) {
    aliases.push({ type: match[1].trim(), alias: match[2].trim() });
    return match[1].trim();
  }
  return element;
}

/**
 * Parse a list of elements, returning both the list and any 'as' aliases found.
 * The aliases are returned separately so they can be used to create sequences.
 * @param {string} line - The line to parse
 * @returns {{list: Array|Object, aliases: Array<{type: string, alias: string}>}}
 */
function parseList(line) {
  const aliases = [];

  if (/^[^\(]*%/.test(line)) {
    const els = splitAfter(line)
      .split(",")
      .map((s) => s.trim());
    const obj = {};
    for (let j = 0, len = els.length; j < len; j++) {
      const element = els[j];
      const [perc, el] = splitOn(element, "%");
      const type = captureInlineSequence(aliases, parens(el).trim());
      obj[type] = parseInt(perc.trim());
    }
    return { list: obj, aliases };
  } else if (!hasColonOutsideParens(line)) {
    const els = line.split(",").map((s) => s.trim());
    const result = [];
    for (let i = 0; i < els.length; i++) {
      const type = captureInlineSequence(aliases, els[i]);
      result.push(type);
    }
    return { list: result, aliases };
  } else {
    const els = splitAfter(line)
      .split(",")
      .map((s) => s.trim());
    const result = [];
    for (let i = 0, len = els.length; i < len; i++) {
      const el = els[i];
      const type = captureInlineSequence(aliases, parens(el));
      result.push(type);
    }
    return { list: result, aliases };
  }
}

/**
 * Add or append to a sequence in the props object.
 * If a sequence with the given id exists, append the alias to its options.
 * Otherwise, create a new sequence with the id and alias.
 * @param {Object} props - The props object containing sequences
 * @param {string} id - The sequence id (typically the type name)
 * @param {string} alias - The alias to add to the sequence options
 */
function addToSequence(props, id, alias) {
  props.sequences = props.sequences ?? [];
  const existing = props.sequences.find((seq) => seq.id === id);
  if (existing) {
    existing.options.push(alias);
  } else {
    props.sequences.push({ id, options: [alias] });
  }
}

function parseLine(field, props, line) {
  const STARTS_WS = /^\s/;
  if (WS.test(line) || line.startsWith("//")) {
    // WHITESPACE
  } else if (STARTS_WS.test(line)) {
    // INDENTED PROPERTY LINE
    let [command, content] = splitOn(line.trim());

    if (command.startsWith("$")) {
      const variable = VARIABLES.get(command);
      Object.keys(variable.ch).forEach((propName) => (props.ch[propName] = variable.ch[propName]));
    } else if (command === "ch" || command === "contents") {
      field = command;
    } else if (command === "allOf" || command === "oneOf") {
      const { list, aliases } = parseList(line);
      props[field][command] = list;
      // Create sequences from any 'as' aliases found
      for (const { type, alias } of aliases) {
        addToSequence(props, type, alias);
      }
    } else if (command.startsWith("seq-")) {
      props.sequences = props.sequences ?? [];
      let [, id] = command.split("-");
      props.sequences.push({ id, options: content.split("/").map((s) => s.trim()) });
    } else if (command === "descr") {
      props["description"] = content.trim();
    } else if (STRING_PROP_FIELDS.includes(command)) {
      props[command] = content.trim();
    } else if (LIST_PROP_FIELDS.includes(command)) {
      props[command] = parseList(content).list;
    } else {
      // 1d3-1: <list>
      let [command, content] = splitOn(line.trim());
      command = "someOf:" + command;
      const prop = command === "contents" ? "contents" : "ch";
      props[prop][command] = parseList(line).list;
    }
  } else {
    if (props !== null) {
      flush(props);
      field = "ch";
    }
    return [field, startConfig(line)];
  }
  return [field, props];
}

// Type "Name" (attr) #ID
function startConfig(line) {
  if (!NEW_CONFIG_REGEX.test(line)) {
    throw new Error(`Invalid location: ${line} , MUST be in format 'Type "Name" (attr) #ID'`);
  }
  const type = line.split(/[\"\(\#)]/)[0].trim();
  const [hasParens, , givenAtts] = parseDelimiters(line, "(", ")");
  const atts = hasParens ? givenAtts.split(" ").map((a) => a.trim()) : [];
  const [hasQuotes, , givenName] = parseDelimiters(line, '"', '"');
  const name = hasQuotes ? givenName : type;
  const hasId = line.includes("#");
  const id = hasId ? line.split("#")[1].trim() : null;
  const props = { id, type, name, ch: {}, contents: {}, tags: [] };
  atts.forEach((att) => props.tags.push(att));
  return props;
}

export async function /* LocationTemplate[]> */ locationsLoader(fileName) {
  const lines = await indentLoader(fileName);
  let props = null;
  let field = "ch";
  for (const line of lines) {
    [field, props] = parseLine(field, props, line);
  }
  flush(props);
  return LOCATIONS;
}

export function yamlishParser({ lists = [], bools = [], ints = [], keyProp = "name" } = {}) {
  const STARTS_WS = /^\s/;
  return function parseEntry(lines) {
    const entries = [];
    let entry = {};

    for (const aLine of lines) {
      let line = aLine.trim();
      if (line.length === 0) {
        entries.push(entry);
        entry = {};
      } else if (line.startsWith("//")) {
        continue;
      } else if (STARTS_WS.test(aLine)) {
        const [key, value] = line.split(":").map((s) => s.trim());
        if (lists.includes(key)) {
          entry[key] = value.split(",").map((s) => s.trim());
        } else if (ints.includes(key)) {
          entry[key] = parseInt(value);
        } else if (bools.includes(key)) {
          entry[key] = value.toLowerCase() === "true";
        } else {
          entry[key] = value;
        }
      } else {
        entry[keyProp] = line;
      }
    }
    if (Object.keys(entry).length > 0) {
      entries.push(entry);
    }
    return entries;
  };
}