import { useEffect, useMemo, useRef, useState } from "react";
import ContentSection from "./components/ContentSection";
import ConfigSection from "./components/ConfigSection";
import EditorToolbar from "./components/EditorToolbar";
import StatusFooter from "./components/StatusFooter";
import TopNavTabs from "./components/TopNavTabs";
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
import {
  CONFIG_TAB_TO_KEY,
  DIALOGUE_NODE_FIELD_ORDER,
  DIALOGUE_REACTION_TYPES,
  EDIT_TABS_BY_TYPE,
  EDIT_TAB_FIELDS_BY_TYPE,
  FALLBACK_CONDITION_TYPES,
  FIELD_ORDER_BY_TYPE,
  FLOW_KIND_LABELS,
  ROOT_KEY_BY_TYPE,
  TYPE_LABELS,
  fetchJsonOrThrow,
  formatTypeLabel,
  isPlainObject,
  buildDefaultRecord,
  getRecordLabel,
  normalizeHexColor,
  getSpritePalette,
  buildSpritePreviewDataUrl,
  getSpriteEditorSize,
  getSpriteCellSymbol,
  paintSpriteCell,
  toFieldLabel,
  normalizeStringList,
  parseCsv,
  getCatalogEntryIdValue,
  normalizeCatalogEntryIdentity,
  normalizeCatalogMetaIdentity,
  createDefaultSystemActionEntries,
  createDefaultColorEntries,
  setUnifiedColorEntries,
  getConditionBaseType,
  getDialogueNodes,
  normalizeDialogueNodesForSave,
  normalizeNpcRecordForLoad,
  normalizeNpcPayloadForLoad,
  normalizeNpcRecordForSave,
  normalizeNpcPayloadForSave,
  normalizeImagesPayloadForSave,
  normalizeSpritePayloadForSave,
  normalizeTileRecordForSave,
  normalizeTilesPayloadForSave,
  createFlowStep,
  getPlainObjectArray,
  normalizePlainObjectArray,
  nodeToFlowSteps,
  flowStepsToNode,
  cloneSandbox,
  parseItemValue,
  resolveNodeConditionTextAndNext,
  getVisibleChoices,
  applyReactionToSandbox,
  applyNodeReactionsToSandbox,
} from "./editorCore";
import type {
  CatalogEntry,
  CatalogMeta,
  ConfigCatalogKey,
  ConfigTabLabel,
  DialogueChoice,
  DialogueFlowStep,
  DialogueSandbox,
  JsonObject,
  JsonValue,
} from "./editorCore";

type ValidationWorkerResponse = {
  requestId: number;
  issues: string[];
};

const LAST_ACTIVE_TYPE_STORAGE_KEY = "worldshaper:lastActiveType";
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";

function getContentRecordsForType(contentDataByType: Record<string, JsonObject>, type: string): JsonObject[] {
  const payload = contentDataByType[type];
  const root = ROOT_KEY_BY_TYPE[type];
  const raw = root ? payload?.[root] : null;
  if (!Array.isArray(raw)) {
    return [];
  }
  return raw.filter((entry) => isPlainObject(entry));
}

function App() {
  const [types, setTypes] = useState<string[]>([]);
  const [activeType, setActiveType] = useState<string>("");
  const [selectedIndex, setSelectedIndex] = useState<number>(0);
  const [selectedDialogueNodeIndex, setSelectedDialogueNodeIndex] = useState<number>(0);
  const [collapsedStepIds, setCollapsedStepIds] = useState<Record<string, boolean>>({});
  const [draggingStepId, setDraggingStepId] = useState<string>("");
  const [dropTargetStepId, setDropTargetStepId] = useState<string>("");
  const [activeSection, setActiveSection] = useState<"content" | "config">("content");
  const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabLabel>("Conditions");
  const [selectedConfigIndexByTab, setSelectedConfigIndexByTab] = useState<Record<string, number>>({});
  const [activeEditTabByType, setActiveEditTabByType] = useState<Record<string, string>>({});
  const [catalogMeta, setCatalogMeta] = useState<CatalogMeta>({
    conditions: [],
    itemActions: [],
    systemActions: [],
    effects: [],
    colors: createDefaultColorEntries(0),
  });
  const [contentDataByType, setContentDataByType] = useState<Record<string, JsonObject>>({});
  const [simSandbox, setSimSandbox] = useState<DialogueSandbox>({ questStarted: [], questCompleted: [], inventory: { copper_ore: 0 } });
  const [simCurrentNodeId, setSimCurrentNodeId] = useState<string>("");
  const [simText, setSimText] = useState<string>("");
  const [simChoices, setSimChoices] = useState<DialogueChoice[]>([]);
  const [simFallbackNextId, setSimFallbackNextId] = useState<string>("");
  const [simEnded, setSimEnded] = useState<boolean>(true);
  const [jsonText, setJsonText] = useState<string>("");
  const [savedContentJsonText, setSavedContentJsonText] = useState<string>("");
  const [savedCatalogMetaJsonText, setSavedCatalogMetaJsonText] = useState<string>("");
  const [recordsDraft, setRecordsDraft] = useState<JsonObject[] | null>(null);
  const [recordJsonDraft, setRecordJsonDraft] = useState<string>("");
  const [recordDraftPending, setRecordDraftPending] = useState<boolean>(false);
  const [npcSpriteSearchQuery, setNpcSpriteSearchQuery] = useState<string>("");
  const [activeSpritePaintSymbol, setActiveSpritePaintSymbol] = useState<string>("0");
  const [status, setStatus] = useState<string>("Loading available content types...");
  const [error, setError] = useState<string>("");
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isSaving, setIsSaving] = useState<boolean>(false);
  const [validationIssues, setValidationIssues] = useState<string[]>([]);
  const validationWorkerRef = useRef<Worker | null>(null);
  const validationRequestIdRef = useRef<number>(0);

  const contentTypes = useMemo(() => types.filter((type) => Object.prototype.hasOwnProperty.call(ROOT_KEY_BY_TYPE, type) && type !== "npcs"), [types]);
  const configTabLabels: ConfigTabLabel[] = ["Conditions", "Item Actions", "System Actions", "Effects", "Colors"];
  const activeConfigKey = useMemo(() => CONFIG_TAB_TO_KEY[activeConfigTab], [activeConfigTab]);

  function readLastActiveType(): string {
    try {
      return String(window.localStorage.getItem(LAST_ACTIVE_TYPE_STORAGE_KEY) || "").trim();
    } catch {
      return "";
    }
  }

  function requestActiveType(nextType: string): void {
    const normalizedType = String(nextType || "").trim();
    if (!normalizedType) {
      return;
    }
    setError("");
    setIsLoading(true);
    setStatus(`Loading ${formatTypeLabel(normalizedType)}...`);
    setActiveType(normalizedType);
  }

  useEffect(() => {
    fetchJsonOrThrow<{ types?: string[] }>("/api/types")
      .then((payload) => {
        const nextTypes = Array.isArray(payload.types) ? payload.types : [];
        setTypes(nextTypes);
        if (nextTypes.length > 0) {
          const visibleContentTypes = nextTypes.filter((type) => type !== "npcs" && Object.prototype.hasOwnProperty.call(ROOT_KEY_BY_TYPE, type));
          const storedRawType = readLastActiveType();
          const storedType = storedRawType === "npcs" ? "npc_templates" : storedRawType;
          const preferredType = storedType && visibleContentTypes.includes(storedType)
            ? storedType
            : (visibleContentTypes[0] || nextTypes[0]);
          requestActiveType(preferredType);
        }
        fetchJsonOrThrow<CatalogMeta>("/api/catalog-meta")
          .then((meta) => {
            const normalizedMeta = meta && typeof meta === "object"
              ? normalizeCatalogMetaIdentity(meta)
              : {
                conditions: [],
                itemActions: [],
                systemActions: [],
                effects: [],
                colors: createDefaultColorEntries(0),
              };
            if (!Array.isArray(normalizedMeta.colors) || normalizedMeta.colors.length === 0) {
              normalizedMeta.colors = createDefaultColorEntries(0);
            }
            setCatalogMeta(normalizedMeta);
            setSavedCatalogMetaJsonText(JSON.stringify(normalizedMeta));
          })
          .catch(() => {
            const emptyMeta = {
              conditions: [],
              itemActions: [],
              systemActions: [],
              effects: [],
              colors: createDefaultColorEntries(0),
            };
            setCatalogMeta(emptyMeta);
            setSavedCatalogMetaJsonText(JSON.stringify(emptyMeta));
          });

        if (nextTypes.length > 0) {
          Promise.all(nextTypes.map((type) => fetchJsonOrThrow<JsonObject>(`/api/content/${type}`)))
            .then((payloads) => {
              const nextByType: Record<string, JsonObject> = {};
              nextTypes.forEach((type, index) => {
                const payload = payloads[index];
                const root = ROOT_KEY_BY_TYPE[type];
                // Normalize NPC records on load to flatten position data
                if (type === "npcs" && isPlainObject(payload) && Array.isArray(payload[root])) {
                  payload[root] = (payload[root] as JsonObject[]).map((rec) => normalizeNpcRecordForLoad(rec));
                }
                if (type === "tiles") {
                  const normalizedTiles = normalizeTilesPayloadForSave(payload as JsonValue);
                  if (isPlainObject(normalizedTiles)) {
                    nextByType[type] = normalizedTiles;
                    return;
                  }
                }
                if (type === "images") {
                  const normalizedImages = normalizeImagesPayloadForSave(payload as JsonValue);
                  if (isPlainObject(normalizedImages)) {
                    nextByType[type] = normalizedImages;
                    return;
                  }
                }
                nextByType[type] = payload;
              });
              setContentDataByType(nextByType);
            })
            .catch(() => {
              setContentDataByType({});
            });
        }

        setStatus(nextTypes.length > 0 ? "Select a type to begin editing." : "No content types available.");
      })
      .catch((err: unknown) => {
        setError(String(err));
        setStatus("Failed to load content types.");
      });
  }, []);

  useEffect(() => {
    if (!activeType) {
      return;
    }
    try {
      const persistType = activeType === "npcs" ? "npc_templates" : activeType;
      window.localStorage.setItem(LAST_ACTIVE_TYPE_STORAGE_KEY, persistType);
    } catch {
      // Ignore storage failures (private browsing / blocked storage).
    }
  }, [activeType]);

  useEffect(() => {
    if (!activeType) {
      return;
    }
    fetchJsonOrThrow<JsonValue>(`/api/content/${activeType}`)
      .then((payload) => {
        let normalized = payload;
        if (activeType === "npcs") {
          normalized = normalizeNpcPayloadForLoad(normalized);
          normalized = normalizeNpcPayloadForSave(normalized);
        } else if (activeType === "images") {
          normalized = normalizeImagesPayloadForSave(normalized);
        } else if (activeType === "sprites") {
          normalized = normalizeSpritePayloadForSave(normalized);
        } else if (activeType === "tiles") {
          normalized = normalizeTilesPayloadForSave(normalized);
        }
        const rootKeyForType = ROOT_KEY_BY_TYPE[activeType] || "";
        let nextSelectedIndex = -1;
        if (rootKeyForType && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
          const list = (normalized as JsonObject)[rootKeyForType];
          if (Array.isArray(list) && list.length > 0) {
            nextSelectedIndex = 0;
          }
        }
        const normalizedText = JSON.stringify(normalized, null, 2);
        setJsonText(normalizedText);
        setSavedContentJsonText(normalizedText);
        setRecordsDraft(null);
        if (nextSelectedIndex >= 0 && rootKeyForType) {
          const nextRecords = (normalized as JsonObject)[rootKeyForType];
          const nextSelectedRecord = Array.isArray(nextRecords) ? nextRecords[nextSelectedIndex] : null;
          setRecordJsonDraft(nextSelectedRecord ? JSON.stringify(nextSelectedRecord, null, 2) : "");
        } else {
          setRecordJsonDraft("");
        }
        setRecordDraftPending(false);
        setSelectedIndex(nextSelectedIndex);
        setStatus(`Loaded ${formatTypeLabel(activeType)}.`);
      })
      .catch((err: unknown) => {
        setError(String(err));
        setStatus(`Failed to load ${formatTypeLabel(activeType)}.`);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, [activeType]);

  const parsedJsonError = useMemo(() => {
    if (!jsonText.trim()) {
      return "JSON is empty.";
    }
    try {
      JSON.parse(jsonText);
      return "";
    } catch (err: unknown) {
      return String(err);
    }
  }, [jsonText]);

  const parsedPayload = useMemo(() => {
    if (parsedJsonError) {
      return null;
    }
    try {
      return JSON.parse(jsonText) as JsonObject;
    } catch {
      return null;
    }
  }, [jsonText, parsedJsonError]);

  const rootKey = ROOT_KEY_BY_TYPE[activeType] || "";

  const committedRecords = useMemo(() => {
    if (!parsedPayload || !rootKey) {
      return [] as JsonObject[];
    }
    const list = parsedPayload[rootKey];
    if (!Array.isArray(list)) {
      return [] as JsonObject[];
    }
    const filtered = list.filter((entry) => isPlainObject(entry));
    // Normalize NPC records on load to flatten position data
    if (activeType === "npcs") {
      return filtered.map((entry) => normalizeNpcRecordForLoad(entry));
    }
    return filtered;
  }, [parsedPayload, rootKey, activeType]);

  const records = useMemo(() => (recordsDraft ?? committedRecords), [recordsDraft, committedRecords]);
  const selectedRecordIndex = useMemo(() => {
    if (records.length === 0) {
      return -1;
    }
    if (!Number.isInteger(selectedIndex) || selectedIndex < 0) {
      return -1;
    }
    return Math.min(selectedIndex, records.length - 1);
  }, [records.length, selectedIndex]);

  const isAllRecordsSelected = selectedRecordIndex < 0;
  const baseSelectedRecord = records[selectedRecordIndex] || null;
  const selectedRecordJson = useMemo(() => (baseSelectedRecord ? JSON.stringify(baseSelectedRecord, null, 2) : ""), [baseSelectedRecord]);
  const selectedRecord = useMemo(() => {
    if (isAllRecordsSelected || !baseSelectedRecord) {
      return baseSelectedRecord;
    }
    try {
      const parsed = JSON.parse(recordJsonDraft) as JsonValue;
      if (isPlainObject(parsed)) {
        return activeType === "npcs" ? normalizeNpcRecordForLoad(parsed) : parsed;
      }
    } catch {
      // Keep using the last committed record while draft text is invalid JSON.
    }
    return baseSelectedRecord;
  }, [activeType, isAllRecordsSelected, baseSelectedRecord, recordJsonDraft]);
  const isRecordDraftDirty = useMemo(() => {
    if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord) {
      return false;
    }
    return recordJsonDraft !== selectedRecordJson;
  }, [activeSection, isAllRecordsSelected, recordJsonDraft, baseSelectedRecord, selectedRecordJson]);
  const hasPendingRecordChanges = activeSection === "content" && (Boolean(recordsDraft) || recordDraftPending);
  const hasUnsavedContentChanges = useMemo(() => {
    if (activeSection !== "content") {
      return false;
    }
    return hasPendingRecordChanges || jsonText !== savedContentJsonText;
  }, [activeSection, hasPendingRecordChanges, jsonText, savedContentJsonText]);
  const recordDraftError = useMemo(() => {
    if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord || !isRecordDraftDirty) {
      return "";
    }
    try {
      const parsed = JSON.parse(recordJsonDraft) as JsonValue;
      if (!isPlainObject(parsed)) {
        return "Record draft must be a JSON object.";
      }
      return "";
    } catch (err: unknown) {
      return String(err);
    }
  }, [activeSection, isAllRecordsSelected, baseSelectedRecord, isRecordDraftDirty, recordJsonDraft]);
  const dialogueNodes = useMemo(() => (activeType === "dialogues" ? getDialogueNodes(selectedRecord) : []), [activeType, selectedRecord]);
  const activeDialogueNodeIndex = useMemo(() => {
    if (dialogueNodes.length === 0) {
      return 0;
    }
    if (!Number.isInteger(selectedDialogueNodeIndex) || selectedDialogueNodeIndex < 0) {
      return 0;
    }
    return Math.min(selectedDialogueNodeIndex, dialogueNodes.length - 1);
  }, [dialogueNodes.length, selectedDialogueNodeIndex]);
  const selectedDialogueNode = dialogueNodes[activeDialogueNodeIndex] || null;
  const dialogueNodeById = useMemo(() => {
    const map = new Map<string, JsonObject>();
    dialogueNodes.forEach((node) => {
      const id = String(node.id || "").trim();
      if (id) {
        map.set(id, node);
      }
    });
    return map;
  }, [dialogueNodes]);
  const conditionCatalogMap = useMemo(() => {
    const map = new Map<string, CatalogEntry>();
    (Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : []).forEach((entry) => {
      const key = String(entry?.key || "").trim();
      if (key) {
        map.set(key, entry);
      }
    });
    return map;
  }, [catalogMeta.conditions]);
  const conditionTypeOptions = useMemo(() => {
    const keys = (Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : [])
      .map((entry) => String(entry?.key || "").trim())
      .filter(Boolean);
    return keys.length > 0 ? keys : FALLBACK_CONDITION_TYPES;
  }, [catalogMeta.conditions]);

  const activeConfigEntries = useMemo(() => {
    const next = catalogMeta[activeConfigKey];
    return Array.isArray(next) ? next : [];
  }, [activeConfigKey, catalogMeta]);

  const selectedConfigIndex = useMemo(() => {
    const candidate = Number(selectedConfigIndexByTab[activeConfigKey] ?? 0);
    if (!Number.isInteger(candidate) || candidate < 0) {
      return 0;
    }
    if (candidate >= activeConfigEntries.length) {
      return Math.max(0, activeConfigEntries.length - 1);
    }
    return candidate;
  }, [activeConfigEntries.length, activeConfigKey, selectedConfigIndexByTab]);

  const selectedConfigEntry = activeConfigEntries[selectedConfigIndex] || null;

  const hasUnsavedConfigChanges = useMemo(() => {
    return JSON.stringify(catalogMeta) !== savedCatalogMetaJsonText;
  }, [catalogMeta, savedCatalogMetaJsonText]);

  const selectedDialogueFieldEntries = useMemo(() => {
    if (!selectedDialogueNode) {
      return [] as Array<[string, JsonValue]>;
    }

    const entryMap = new Map(Object.entries(selectedDialogueNode));
    const ordered: Array<[string, JsonValue]> = [];

    DIALOGUE_NODE_FIELD_ORDER.forEach((key) => {
      if (!entryMap.has(key)) {
        return;
      }
      ordered.push([key, entryMap.get(key) as JsonValue]);
      entryMap.delete(key);
    });

    const rest = Array.from(entryMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
    return ordered.concat(rest);
  }, [selectedDialogueNode]);
  const selectedFlowSteps = useMemo(() => {
    if (!selectedDialogueNode) {
      return [] as DialogueFlowStep[];
    }
    return nodeToFlowSteps(selectedDialogueNode);
  }, [selectedDialogueNode]);
  const selectedItemActions = useMemo(() => (activeType === "items" ? getItemActions(selectedRecord) : []), [activeType, selectedRecord]);
  const selectedQuestSteps = useMemo(() => (activeType === "quests" ? getQuestSteps(selectedRecord) : []), [activeType, selectedRecord]);
  const selectedSpriteEditorSize = useMemo(() => {
    if ((activeType !== "sprites" && activeType !== "tiles") || !selectedRecord) {
      return { width: 1, height: 1 };
    }
    return getSpriteEditorSize(selectedRecord);
  }, [activeType, selectedRecord]);
  const npcSpriteOptions = useMemo(() => getContentRecordsForType(contentDataByType, "sprites"), [contentDataByType]);
  const allNpcRecords = useMemo(() => getContentRecordsForType(contentDataByType, "npcs"), [contentDataByType]);
  const allNpcTemplateRecords = useMemo(() => getContentRecordsForType(contentDataByType, "npc_templates"), [contentDataByType]);
  const npcTownOptions = useMemo(() => {
    const values = new Set<string>();
    getContentRecordsForType(contentDataByType, "npcs").forEach((record) => {
      const townId = String(record.mapId || "").trim();
      if (townId) {
        values.add(townId);
      }
    });
    return Array.from(values).sort((a, b) => a.localeCompare(b));
  }, [contentDataByType]);
  const npcFactionOptions = useMemo(() => {
    return getContentRecordsForType(contentDataByType, "factions")
      .map((record) => {
        const id = String(record.id || "").trim();
        const name = String(record.name || "").trim();
        return id ? { id, name } : null;
      })
      .filter((entry): entry is { id: string; name: string } => entry !== null)
      .sort((a, b) => a.id.localeCompare(b.id));
  }, [contentDataByType]);
  const filteredNpcSpriteOptions = useMemo(() => {
    const query = npcSpriteSearchQuery.trim().toLowerCase();
    if (!query) {
      return npcSpriteOptions;
    }
    return npcSpriteOptions.filter((spriteRecord) => {
      const id = String(spriteRecord.id || "").toLowerCase();
      const name = String(spriteRecord.name || "").toLowerCase();
      return id.includes(query) || name.includes(query);
    });
  }, [npcSpriteOptions, npcSpriteSearchQuery]);
  const dialogueNodeIds = useMemo(() => {
    return dialogueNodes
      .map((node) => String(node.id || "").trim())
      .filter(Boolean);
  }, [dialogueNodes]);

  const resolvedActiveSpritePaintSymbol = useMemo(() => {
    if ((activeType !== "sprites" && activeType !== "tiles") || !selectedRecord) {
      return activeSpritePaintSymbol;
    }
    const symbols = (Array.isArray(catalogMeta.colors) ? catalogMeta.colors : [])
      .map((entry) => String(entry.key || entry.sourceKey || entry.originalName || "").trim().charAt(0))
      .filter(Boolean);
    if (!symbols.includes(".")) {
      symbols.push(".");
    }
    if (symbols.length === 0) {
      return "0";
    }
    return symbols.includes(activeSpritePaintSymbol) ? activeSpritePaintSymbol : symbols[0];
  }, [activeType, selectedRecord, catalogMeta.colors, activeSpritePaintSymbol]);

  useEffect(() => {
    setUnifiedColorEntries(Array.isArray(catalogMeta.colors) ? catalogMeta.colors : []);
  }, [catalogMeta.colors]);

  useEffect(() => {
    const worker = new Worker(new URL("./workers/validationWorker.ts", import.meta.url), { type: "module" });
    validationWorkerRef.current = worker;
    worker.onmessage = (event: MessageEvent<ValidationWorkerResponse>) => {
      const { requestId, issues } = event.data;
      if (requestId !== validationRequestIdRef.current) {
        return;
      }
      setValidationIssues(Array.isArray(issues) ? issues : []);
    };
    return () => {
      worker.terminate();
      validationWorkerRef.current = null;
    };
  }, []);

  useEffect(() => {
    if (!parsedPayload || !rootKey) {
      validationRequestIdRef.current += 1;
      return;
    }
    const worker = validationWorkerRef.current;
    if (!worker) {
      return;
    }
    const requestId = validationRequestIdRef.current + 1;
    validationRequestIdRef.current = requestId;
    worker.postMessage({
      requestId,
      activeType,
      rootKey,
      parsedPayload,
      records,
    });
  }, [activeType, parsedPayload, records, rootKey]);
  const visibleValidationIssues = parsedPayload && rootKey ? validationIssues : [];

  const selectedFieldEntries = useMemo(() => {
    if (!selectedRecord) {
      return [] as Array<[string, JsonValue]>;
    }

    const priority = FIELD_ORDER_BY_TYPE[activeType] || [];
    const entryMap = new Map(Object.entries(selectedRecord));
    const ordered: Array<[string, JsonValue]> = [];

    priority.forEach((key) => {
      if (!entryMap.has(key)) {
        return;
      }
      ordered.push([key, entryMap.get(key) as JsonValue]);
      entryMap.delete(key);
    });

    const rest = Array.from(entryMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
    const merged = ordered.concat(rest);
    if (activeType === "npcs") {
      const seenCanonical = new Set<string>();
      return merged.filter(([key]) => {
        const lower = String(key || "").toLowerCase();
        if (lower === "x" || lower === "y" || lower === "role" || lower === "mapid" || lower === "townid") {
          return false;
        }

        const canonical =
          lower === "position" ? "position"
          : lower === "townid" ? "mapId"
          : lower === "mapid" ? "mapId"
          : lower === "nameoverride" ? "name"
          : lower === "factionoverride" ? "faction"
          : lower === "spriteidoverride" ? "spriteId"
          : lower === "dialogueidoverride" ? "dialogueId"
          : key;

        if (seenCanonical.has(canonical)) {
          return false;
        }
        seenCanonical.add(canonical);
        return true;
      });
    }
    return merged;
  }, [activeType, selectedRecord]);

  const activeEditTabs = useMemo(() => {
    const tabs = EDIT_TABS_BY_TYPE[activeType];
    return Array.isArray(tabs) && tabs.length > 0 ? tabs : ["General"];
  }, [activeType]);

  const activeEditTab = useMemo(() => {
    const candidate = String(activeEditTabByType[activeType] || "");
    return activeEditTabs.includes(candidate) ? candidate : activeEditTabs[0];
  }, [activeEditTabByType, activeEditTabs, activeType]);

  const selectedFieldEntriesForTab = useMemo(() => {
    const tabFields = EDIT_TAB_FIELDS_BY_TYPE[activeType]?.[activeEditTab];
    if (!Array.isArray(tabFields) || tabFields.length === 0) {
      return selectedFieldEntries;
    }
    const allow = new Set(tabFields);
    let filtered = selectedFieldEntries.filter(([key]) => allow.has(key));
    if (activeType === "factions" && activeEditTab === "General") {
      const hasColorField = filtered.some(([key]) => String(key || "").toLowerCase() === "color");
      if (!hasColorField) {
        filtered = [...filtered, ["color", normalizeHexColor(selectedRecord?.color)]];
      }
    }
    if (activeType === "items" && activeEditTab === "Actions") {
      return filtered.filter(([key]) => !["actionsList", "effects", "triggers"].includes(key));
    }
    if (activeType === "quests" && activeEditTab === "Conditions") {
      return filtered.filter(([key]) => key !== "requirements");
    }
    if (activeType === "quests" && activeEditTab === "Steps") {
      return filtered.filter(([key]) => key !== "steps");
    }
    if (activeType === "npcs" && activeEditTab === "General") {
      return filtered.filter(([key]) => {
        const lower = String(key || "").toLowerCase();
        return lower !== "x" && lower !== "y";
      });
    }
    return filtered;
  }, [activeType, activeEditTab, selectedFieldEntries, selectedRecord]);

  function patchCatalogEntries(
    key: ConfigCatalogKey,
    updater: (entries: CatalogEntry[]) => CatalogEntry[],
  ): void {
    setCatalogMeta((prev) => {
      const current = Array.isArray(prev[key]) ? prev[key] : [];
      const next = updater(current).map((entry, index) => normalizeCatalogEntryIdentity(entry, `${key}_${index + 1}`));
      return {
        ...prev,
        schemaVersion: 1,
        [key]: next,
      };
    });
  }

  function patchActiveConfigEntries(updater: (entries: CatalogEntry[]) => CatalogEntry[]): void {
    patchCatalogEntries(activeConfigKey, updater);
  }

  function updateActiveConfigEntry(index: number, patch: Partial<CatalogEntry>): void {
    if (activeConfigKey === "colors") {
      setStatus("Colors are read-only in this editor.");
      return;
    }
    patchActiveConfigEntries((entries) => entries.map((entry, idx) => (idx === index ? { ...entry, ...patch } : entry)));
  }

  function addConfigEntry(): void {
    if (activeConfigKey === "colors") {
      setStatus("Colors are read-only in this editor.");
      return;
    }
    if (activeConfigKey === "systemActions" && activeConfigEntries.length === 0) {
      const seed = Date.now();
      patchActiveConfigEntries((entries) => (entries.length > 0 ? entries : createDefaultSystemActionEntries(seed)));
      setSelectedConfigIndexByTab((prev) => ({
        ...prev,
        [activeConfigKey]: 0,
      }));
      setStatus("Added starter System Actions.");
      return;
    }
    const slug = `${activeConfigKey}_${Date.now()}`;
    patchActiveConfigEntries((entries) => ([
      ...entries,
      {
        entryId: `${activeConfigKey}-${Date.now()}`,
        sourceKey: slug,
        key: slug,
        originalName: slug,
        description: "",
        sublistType: "",
        displayKeys: [],
        passKeys: [],
      },
    ]));
    setSelectedConfigIndexByTab((prev) => ({
      ...prev,
      [activeConfigKey]: activeConfigEntries.length,
    }));
    setStatus(`Added ${activeConfigTab.slice(0, -1)} entry.`);
  }

  function deleteActiveConfigEntry(): void {
    if (activeConfigKey === "colors") {
      setStatus("Colors are read-only in this editor.");
      return;
    }
    if (!selectedConfigEntry) {
      return;
    }
    patchActiveConfigEntries((entries) => entries.filter((_, idx) => idx !== selectedConfigIndex));
    setSelectedConfigIndexByTab((prev) => ({
      ...prev,
      [activeConfigKey]: Math.max(0, selectedConfigIndex - 1),
    }));
    setStatus(`Deleted ${activeConfigTab.slice(0, -1)} entry.`);
  }

  function moveConfigEntry(direction: -1 | 1): void {
    if (activeConfigKey === "colors") {
      setStatus("Colors are read-only in this editor.");
      return;
    }
    patchActiveConfigEntries((entries) => {
      const target = selectedConfigIndex + direction;
      if (selectedConfigIndex < 0 || target < 0 || target >= entries.length) {
        return entries;
      }
      const next = [...entries];
      const [moved] = next.splice(selectedConfigIndex, 1);
      next.splice(target, 0, moved);
      setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: target }));
      return next;
    });
  }

  async function handleSaveConfig(): Promise<void> {
    if (activeConfigKey === "colors") {
      setStatus("Colors are read-only in this editor.");
      return;
    }
    if (!hasUnsavedConfigChanges) {
      setError("No config changes detected.");
      setStatus(`No changes to save for ${activeConfigTab}.`);
      return;
    }

    setIsSaving(true);
    setError("");
    setStatus(`Saving ${activeConfigTab}...`);
    try {
      const payload: CatalogMeta = {
        schemaVersion: 1,
        conditions: Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : [],
        itemActions: Array.isArray(catalogMeta.itemActions) ? catalogMeta.itemActions : [],
        systemActions: Array.isArray(catalogMeta.systemActions) ? catalogMeta.systemActions : [],
        effects: Array.isArray(catalogMeta.effects) ? catalogMeta.effects : [],
        colors: Array.isArray(catalogMeta.colors) ? catalogMeta.colors : [],
      };
      const res = await fetch("/api/catalog-meta", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!res.ok) {
        const body = await res.text();
        throw new Error(`Catalog save failed (${res.status}): ${body.slice(0, 180)}`);
      }
      const refreshed = await fetchJsonOrThrow<CatalogMeta>("/api/catalog-meta");
      const normalizedRefreshed = refreshed && typeof refreshed === "object"
        ? normalizeCatalogMetaIdentity(refreshed)
        : {
          conditions: [],
          itemActions: [],
          systemActions: [],
          effects: [],
          colors: createDefaultColorEntries(0),
        };
      if (!Array.isArray(normalizedRefreshed.colors) || normalizedRefreshed.colors.length === 0) {
        normalizedRefreshed.colors = createDefaultColorEntries(0);
      }
      setCatalogMeta(normalizedRefreshed);
      setSavedCatalogMetaJsonText(JSON.stringify(normalizedRefreshed));
      setStatus(`Saved ${activeConfigTab}.`);
    } catch (err: unknown) {
      setError(String(err));
      setStatus(`Save failed for ${activeConfigTab}.`);
    } finally {
      setIsSaving(false);
    }
  }

  function handleRawJsonEditorChange(nextRaw: string): void {
    if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord) {
      setJsonText(nextRaw);
      return;
    }

    setRecordJsonDraft(nextRaw);
    setRecordDraftPending(true);
  }

  function commitPendingRecordChanges(): void {
    if (!hasPendingRecordChanges) {
      return;
    }

    let nextRecords = records;
    if (isRecordDraftDirty && !isAllRecordsSelected && selectedRecordIndex >= 0) {
      let parsedRecord: JsonValue;
      try {
        parsedRecord = JSON.parse(recordJsonDraft) as JsonValue;
      } catch (err: unknown) {
        setError(`Record JSON error: ${String(err)}`);
        return;
      }

      if (!isPlainObject(parsedRecord)) {
        setError("Record JSON error: Record draft must be a JSON object.");
        return;
      }

      if (activeType === "npcs") {
        parsedRecord = normalizeNpcRecordForLoad(parsedRecord);
      }

      nextRecords = records.map((entry, index) => (index === selectedRecordIndex ? parsedRecord : entry));
    }

    setError("");
    commitRecords(nextRecords);
    setRecordsDraft(null);
    setRecordDraftPending(false);
    const nextSelectedIndex = nextRecords.length === 0
      ? -1
      : (selectedRecordIndex < 0 ? -1 : Math.min(selectedRecordIndex, nextRecords.length - 1));
    setSelectedIndex(nextSelectedIndex);
    if (nextSelectedIndex >= 0 && nextSelectedIndex < nextRecords.length) {
      setRecordJsonDraft(JSON.stringify(nextRecords[nextSelectedIndex], null, 2));
    } else {
      setRecordJsonDraft("");
    }
    setStatus("Record changes committed.");
  }

  function revertPendingRecordChanges(): void {
    setRecordsDraft(null);
    setRecordDraftPending(false);
    const nextSelectedIndex = committedRecords.length === 0
      ? -1
      : (selectedRecordIndex < 0 ? -1 : Math.min(selectedRecordIndex, committedRecords.length - 1));
    setSelectedIndex(nextSelectedIndex);
    if (nextSelectedIndex >= 0 && nextSelectedIndex < committedRecords.length) {
      setRecordJsonDraft(JSON.stringify(committedRecords[nextSelectedIndex], null, 2));
    } else {
      setRecordJsonDraft("");
    }
    setError("");
    setStatus("Record changes reverted.");
  }

  function selectRecordIndex(nextIndex: number): void {
    if (activeSection === "content" && hasPendingRecordChanges && nextIndex !== selectedRecordIndex) {
      setError("Pending changes are unsaved");
      return;
    }
    setError("");
    setSelectedIndex(nextIndex);
    if (nextIndex >= 0 && nextIndex < records.length) {
      setRecordJsonDraft(JSON.stringify(records[nextIndex], null, 2));
    } else {
      setRecordJsonDraft("");
    }
    setRecordDraftPending(false);
  }

  function commitRecords(nextRecords: JsonObject[]): void {
    if (!parsedPayload || !rootKey) {
      return;
    }
    const normalizedRecords = activeType === "npcs"
      ? nextRecords.map((record) => normalizeNpcRecordForSave(record))
      : activeType === "tiles"
        ? nextRecords.map((record) => normalizeTileRecordForSave(record))
      : nextRecords;
    const nextPayload: JsonObject = {
      ...parsedPayload,
      [rootKey]: normalizedRecords,
    };
    setJsonText(JSON.stringify(nextPayload, null, 2));
  }

  function commitSelectedRecord(nextRecord: JsonObject): void {
    setRecordJsonDraft(JSON.stringify(nextRecord, null, 2));
    setRecordDraftPending(true);
  }

  function handlePrimitiveFieldChange(key: string, nextRaw: string): void {
    if (!selectedRecord) {
      return;
    }

    const currentValue = selectedRecord[key];
    let nextValue: JsonValue = nextRaw;

    if (typeof currentValue === "number") {
      const parsed = Number(nextRaw);
      nextValue = Number.isFinite(parsed) ? parsed : 0;
    } else if (typeof currentValue === "boolean") {
      nextValue = nextRaw === "true";
    } else if (currentValue === null) {
      nextValue = nextRaw.trim().length === 0 ? null : nextRaw;
    }

    commitSelectedRecord({
      ...selectedRecord,
      [key]: nextValue,
    });
  }

  function handleNpcPositionFieldChange(axis: "x" | "y", nextRaw: string): void {
    if (!selectedRecord) {
      return;
    }
    const parsed = Number(nextRaw);
    const nextNumber = Number.isFinite(parsed) ? parsed : 0;
    const position = isPlainObject(selectedRecord.position) ? (selectedRecord.position as JsonObject) : {};
    commitSelectedRecord({
      ...selectedRecord,
      position: {
        ...position,
        [axis]: nextNumber,
      },
    });
  }

  function addRecord(): void {
    const baseRecord = buildDefaultRecord(activeType, records);
    const nextRecord = activeType === "npcs" ? normalizeNpcRecordForLoad(baseRecord) : baseRecord;
    const nextRecords = records.concat(nextRecord);
    setRecordsDraft(nextRecords);
    setRecordDraftPending(false);
    setSelectedIndex(nextRecords.length - 1);
    setRecordJsonDraft(JSON.stringify(nextRecord, null, 2));
    setError("");
    setStatus(`Added new ${formatTypeLabel(activeType)} record. Pending commit.`);
  }

  function deleteSelectedRecord(): void {
    if (!selectedRecord) {
      return;
    }
    const confirmed = window.confirm("Delete selected record?");
    if (!confirmed) {
      return;
    }
    const nextRecords = records.filter((_, index) => index !== selectedRecordIndex);
    setRecordsDraft(nextRecords);
    setRecordDraftPending(false);
    const nextSelectedIndex = nextRecords.length > 0 ? Math.max(0, selectedRecordIndex - 1) : -1;
    setSelectedIndex(nextSelectedIndex);
    setRecordJsonDraft(nextSelectedIndex >= 0 ? JSON.stringify(nextRecords[nextSelectedIndex], null, 2) : "");
    setError("");
    setStatus(`Deleted ${formatTypeLabel(activeType)} record. Pending commit.`);
  }

  function handleDialogueNodeFieldChange(key: string, nextRaw: string): void {
    if (!selectedRecord || !selectedDialogueNode) {
      return;
    }

    const currentValue = selectedDialogueNode[key];
    let nextValue: JsonValue = nextRaw;

    if (typeof currentValue === "number") {
      const parsed = Number(nextRaw);
      nextValue = Number.isFinite(parsed) ? parsed : 0;
    } else if (typeof currentValue === "boolean") {
      nextValue = nextRaw === "true";
    } else if (currentValue === null) {
      nextValue = nextRaw.trim().length === 0 ? null : nextRaw;
    }

    const nextNodes = dialogueNodes.map((node, index) => {
      if (index !== activeDialogueNodeIndex) {
        return node;
      }
      return {
        ...node,
        [key]: nextValue,
      };
    });

    commitSelectedRecord({
      ...selectedRecord,
      dialogueNodes: nextNodes,
    });
  }

  function addDialogueNode(): void {
    if (!selectedRecord) {
      return;
    }

    const nextNode: JsonObject = {
      id: `node_${Date.now()}`,
      description: "",
      text: "",
      nextNodeId: "",
      order: dialogueNodes.length + 1,
      conditions: [],
      reactions: [],
      choices: [],
    };

    const nextNodes = normalizeDialogueNodesForSave(dialogueNodes.concat(nextNode));
    commitSelectedRecord({
      ...selectedRecord,
      dialogueNodes: nextNodes,
    });
    setSelectedDialogueNodeIndex(nextNodes.length - 1);
    setStatus("Added dialogue node.");
  }

  function deleteDialogueNode(): void {
    if (!selectedRecord || !selectedDialogueNode) {
      return;
    }

    const confirmed = window.confirm("Delete selected dialogue node?");
    if (!confirmed) {
      return;
    }

    const nextNodes = normalizeDialogueNodesForSave(dialogueNodes.filter((_, index) => index !== activeDialogueNodeIndex));
    commitSelectedRecord({
      ...selectedRecord,
      dialogueNodes: nextNodes,
    });
    setSelectedDialogueNodeIndex(Math.max(0, activeDialogueNodeIndex - 1));
    setStatus("Deleted dialogue node.");
  }

  function moveDialogueNode(direction: -1 | 1): void {
    if (!selectedRecord || !selectedDialogueNode) {
      return;
    }
    const targetIndex = activeDialogueNodeIndex + direction;
    if (targetIndex < 0 || targetIndex >= dialogueNodes.length) {
      return;
    }

    const nextNodes = [...dialogueNodes];
    const current = nextNodes[activeDialogueNodeIndex];
    nextNodes[activeDialogueNodeIndex] = nextNodes[targetIndex];
    nextNodes[targetIndex] = current;

    const normalizedNodes = normalizeDialogueNodesForSave(nextNodes);
    commitSelectedRecord({
      ...selectedRecord,
      dialogueNodes: normalizedNodes,
    });
    setSelectedDialogueNodeIndex(targetIndex);
    setStatus("Reordered dialogue node.");
  }

  function patchSelectedDialogueNode(updater: (node: JsonObject) => JsonObject): void {
    if (!selectedRecord || !selectedDialogueNode) {
      return;
    }
    const nextNodes = dialogueNodes.map((node, index) => {
      if (index !== activeDialogueNodeIndex) {
        return node;
      }
      return updater({ ...node });
    });
    commitSelectedRecord({
      ...selectedRecord,
      dialogueNodes: nextNodes,
    });
  }

  function patchFlowSteps(updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]): void {
    patchSelectedDialogueNode((node) => {
      const current = nodeToFlowSteps(node);
      const nextSteps = updater(current).map((step) => ({
        ...step,
        id: String(step.id || `${step.kind}_${Date.now()}`),
      }));
      return flowStepsToNode(node, nextSteps);
    });
  }

  function getFlowStepSummary(step: DialogueFlowStep): string {
    if (step.kind === "condition") {
      const notText = step.conditionNot ? "not " : "";
      const value = String(step.conditionValue || "").trim();
      return `${notText}${step.conditionType || "always"}${value ? ` = ${value}` : ""}`;
    }
    if (step.kind === "choice") {
      return String(step.text || "(empty choice)");
    }
    if (step.kind === "text") {
      return String(step.text || "(empty text)").slice(0, 70);
    }
    if (step.kind === "jump") {
      return String(step.nextId || "(no target)");
    }
    if (step.kind === "action") {
      return `${step.reactionType || "none"}${step.reactionValue ? ` : ${step.reactionValue}` : ""}`;
    }
    return "Conversation ends";
  }

  function isStepCollapsed(stepId: string): boolean {
    return collapsedStepIds[stepId] ?? true;
  }

  function setStepCollapsed(stepId: string, collapsed: boolean): void {
    setCollapsedStepIds((prev) => ({
      ...prev,
      [stepId]: collapsed,
    }));
  }

  function moveFlowStepById(sourceId: string, targetId: string): void {
    if (!sourceId || !targetId || sourceId === targetId) {
      return;
    }
    patchFlowSteps((steps) => {
      const sourceIndex = steps.findIndex((step) => step.id === sourceId);
      const targetIndex = steps.findIndex((step) => step.id === targetId);
      if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
        return steps;
      }
      const next = [...steps];
      const [moved] = next.splice(sourceIndex, 1);
      const insertAt = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
      next.splice(insertAt, 0, moved);
      return next;
    });
  }

  function moveFlowStepByDirection(stepId: string, direction: -1 | 1): void {
    patchFlowSteps((steps) => {
      const index = steps.findIndex((step) => step.id === stepId);
      const target = index + direction;
      if (index < 0 || target < 0 || target >= steps.length) {
        return steps;
      }
      const next = [...steps];
      const [moved] = next.splice(index, 1);
      next.splice(target, 0, moved);
      return next;
    });
  }

  function getItemActions(record: JsonObject | null): JsonObject[] {
    if (!record) {
      return [];
    }
    return getPlainObjectArray(record.actionsList);
  }

  function getQuestSteps(record: JsonObject | null): JsonObject[] {
    if (!record) {
      return [];
    }
    return getPlainObjectArray(record.steps);
  }

  function patchSelectedRecordArray(field: string, updater: (entries: JsonObject[]) => JsonObject[]): void {
    if (!selectedRecord) {
      return;
    }
    const current = getPlainObjectArray(selectedRecord[field]);
    commitSelectedRecord({
      ...selectedRecord,
      [field]: normalizePlainObjectArray(updater(current)),
    });
  }

  function patchSelectedRecord(updater: (record: JsonObject) => JsonObject): void {
    if (!selectedRecord) {
      return;
    }
    commitSelectedRecord(updater({ ...selectedRecord }));
  }

  function getQuestStepSummary(step: JsonObject, index: number): string {
    const name = String(step.name || step.description || "").trim();
    const stepId = String(step.stepID || index + 1);
    return `${stepId}${name ? ` - ${name}` : ""}`;
  }

  function getItemActionSummary(actionEntry: JsonObject, index: number): string {
    const action = String(actionEntry.action || `Action ${index + 1}`).trim();
    const triggerCount = getPlainObjectArray(actionEntry.triggers).length;
    const effectCount = getPlainObjectArray(actionEntry.effects).length;
    const flowCount = Array.isArray(actionEntry.flowSteps) ? actionEntry.flowSteps.length : 0;
    return `${action} • ${triggerCount} trigger${triggerCount === 1 ? "" : "s"} • ${effectCount} effect${effectCount === 1 ? "" : "s"} • ${flowCount} step${flowCount === 1 ? "" : "s"}`;
  }

  function patchItemActionFlowSteps(actionIndex: number, updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]): void {
    patchSelectedRecordArray("actionsList", (entries) => entries.map((entry, idx) => {
      if (idx !== actionIndex) {
        return entry;
      }
      const currentSteps = getPlainObjectArray(entry.flowSteps) as unknown as DialogueFlowStep[];
      return {
        ...entry,
        flowSteps: normalizePlainObjectArray(updater(currentSteps) as unknown as JsonObject[]),
      };
    }));
  }

  function patchQuestSteps(updater: (steps: JsonObject[]) => JsonObject[]): void {
    patchSelectedRecordArray("steps", (steps) => {
      const next = updater(steps);
      return next.map((step, index) => ({
        ...step,
        stepID: index + 1,
      }));
    });
  }

  function addItemActionEntry(): void {
    patchSelectedRecordArray("actionsList", (entries) => ([
      ...entries,
      {
        action: "Use",
        effects: [],
        triggers: [],
        flowSteps: [],
      },
    ]));
  }

  function deleteItemActionEntry(actionIndex: number): void {
    patchSelectedRecordArray("actionsList", (entries) => entries.filter((_, index) => index !== actionIndex));
  }

  function moveItemActionEntry(actionIndex: number, direction: -1 | 1): void {
    patchSelectedRecordArray("actionsList", (entries) => {
      const target = actionIndex + direction;
      if (actionIndex < 0 || target < 0 || target >= entries.length) {
        return entries;
      }
      const next = [...entries];
      const [moved] = next.splice(actionIndex, 1);
      next.splice(target, 0, moved);
      return next;
    });
  }

  function addQuestStepEntry(): void {
    patchQuestSteps((steps) => ([
      ...steps,
      {
        stepID: steps.length + 1,
        id: `step_${Date.now()}`,
        name: "New Quest Step",
        conditionType: "always",
        conditionValue: "",
      },
    ]));
  }

  function deleteQuestStepEntry(stepIndex: number): void {
    patchQuestSteps((steps) => steps.filter((_, index) => index !== stepIndex));
  }

  function moveQuestStepEntry(stepIndex: number, direction: -1 | 1): void {
    patchQuestSteps((steps) => {
      const target = stepIndex + direction;
      if (stepIndex < 0 || target < 0 || target >= steps.length) {
        return steps;
      }
      const next = [...steps];
      const [moved] = next.splice(stepIndex, 1);
      next.splice(target, 0, moved);
      return next;
    });
  }

  function getContentFieldKeysByType(type: string): string[] {
    const records = getContentRecordsForType(contentDataByType, type);
    const keys = new Set<string>();
    records.forEach((record) => {
      Object.keys(record).forEach((key) => {
        if (key !== "schemaVersion") {
          keys.add(key);
        }
      });
    });
    return Array.from(keys);
  }

  function getConditionSublistType(conditionType: string): string {
    return String(conditionCatalogMap.get(getConditionBaseType(conditionType))?.sublistType || "").trim();
  }

  function getConditionDisplayKeys(conditionType: string): string[] {
    return normalizeStringList(conditionCatalogMap.get(getConditionBaseType(conditionType))?.displayKeys);
  }

  function getConditionPassKeys(conditionType: string): string[] {
    return normalizeStringList(conditionCatalogMap.get(getConditionBaseType(conditionType))?.passKeys);
  }

  function formatRecordLabel(record: JsonObject, displayKeys: string[], fallbackLabel: string): string {
    const parts = displayKeys
      .map((field) => {
        const value = record[field];
        if (value === undefined || value === null || String(value).trim().length === 0) {
          return "";
        }
        return String(value);
      })
      .filter(Boolean);
    if (parts.length > 0) {
      return parts.join(" • ");
    }
    return fallbackLabel;
  }

  function getDefaultConditionValue(conditionType: string): string {
    const sublistType = getConditionSublistType(conditionType);
    if (!sublistType) {
      return "";
    }
    const records = getContentRecordsForType(contentDataByType, sublistType);
    if (records.length === 0) {
      return "";
    }
    const passField = getConditionPassKeys(conditionType)[0] || "id";
    const first = records[0];
    if (getConditionBaseType(conditionType) === "item") {
      const itemId = String(first?.id || "").trim();
      return itemId ? `${itemId}:1` : "";
    }
    const value = first?.[passField] ?? first?.id ?? first?.questId ?? "";
    return String(value || "");
  }

  function renderConditionValueField(conditionType: string, currentValue: string, onValueChange: (value: string) => void) {
    const baseType = getConditionBaseType(conditionType);
    const sublistType = getConditionSublistType(baseType);
    const records = sublistType ? getContentRecordsForType(contentDataByType, sublistType) : [];
    const displayKeys = getConditionDisplayKeys(baseType);
    const passKeys = getConditionPassKeys(baseType);

    if (!sublistType || records.length === 0) {
      return <input value={currentValue} onChange={(event) => onValueChange(event.target.value)} placeholder="Value" />;
    }

    if (baseType === "item") {
      const parsed = parseItemValue(currentValue);
      const itemId = parsed.itemId;
      const quantity = parsed.quantity;
      return (
        <div className="condition-composite">
          <select value={itemId} onChange={(event) => onValueChange(`${event.target.value}:${quantity}`)}>
            <option value="">Select item</option>
            {records.map((record, index) => {
              const id = String(record.id || index);
              const label = formatRecordLabel(record, displayKeys, id);
              return (
                <option key={`cond-item-${id}-${index}`} value={id}>
                  {label}
                </option>
              );
            })}
          </select>
          <input
            type="number"
            min="1"
            value={quantity}
            onChange={(event) => onValueChange(`${itemId}:${Math.max(1, Number(event.target.value) || 1)}`)}
            disabled={!itemId}
          />
        </div>
      );
    }

    return (
      <select value={currentValue} onChange={(event) => onValueChange(event.target.value)}>
        <option value="">Select value</option>
        {records.map((record, index) => {
          const passField = passKeys[0] || "id";
          const passValue = String(record?.[passField] ?? record?.id ?? record?.questId ?? index);
          const label = formatRecordLabel(record, displayKeys, passValue);
          return (
            <option key={`cond-value-${sublistType}-${index}-${passValue}`} value={passValue}>
              {label}
            </option>
          );
        })}
      </select>
    );
  }

  function renderSimulationNode(nodeId: string, sandboxInput: DialogueSandbox, applyEnterReactions: boolean): void {
    const sourceSandbox = cloneSandbox(sandboxInput);
    const node = dialogueNodeById.get(nodeId);
    if (!node) {
      setSimCurrentNodeId("");
      setSimText(`Node '${nodeId}' not found.`);
      setSimChoices([]);
      setSimFallbackNextId("");
      setSimEnded(true);
      return;
    }

    const sandboxForEvaluation = applyEnterReactions
      ? applyNodeReactionsToSandbox(node, sourceSandbox)
      : sourceSandbox;
    const conditionOutcome = resolveNodeConditionTextAndNext(node, sandboxForEvaluation);
    const visibleChoices = getVisibleChoices(node, sandboxForEvaluation);

    setSimSandbox(sandboxForEvaluation);
    setSimCurrentNodeId(nodeId);
    setSimText(conditionOutcome.text || "(no text on this node)");
    setSimChoices(visibleChoices);
    setSimFallbackNextId(conditionOutcome.nextId);
    setSimEnded(false);
  }

  function enterSimNode(nodeId: string, sandboxInput?: DialogueSandbox): void {
    const sourceSandbox = sandboxInput ? cloneSandbox(sandboxInput) : cloneSandbox(simSandbox);
    renderSimulationNode(nodeId, sourceSandbox, true);
  }

  function updateSandbox(nextSandbox: DialogueSandbox): void {
    if (!simEnded && simCurrentNodeId) {
      renderSimulationNode(simCurrentNodeId, nextSandbox, false);
      return;
    }
    setSimSandbox(nextSandbox);
  }

  function startSimulation(): void {
    if (dialogueNodes.length === 0) {
      setSimCurrentNodeId("");
      setSimText("No dialogue nodes available for this dialogue record.");
      setSimChoices([]);
      setSimFallbackNextId("");
      setSimEnded(true);
      return;
    }

    const sorted = [...dialogueNodes].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
    const startNodeId = String(sorted[0]?.id || "").trim();
    if (!startNodeId) {
      setSimCurrentNodeId("");
      setSimText("Start node is missing an id.");
      setSimChoices([]);
      setSimFallbackNextId("");
      setSimEnded(true);
      return;
    }
    enterSimNode(startNodeId);
  }

  function chooseSimChoice(choice: DialogueChoice): void {
    let nextSandbox = cloneSandbox(simSandbox);
    if (choice.reactionType && choice.reactionType !== "none") {
      nextSandbox = applyReactionToSandbox(nextSandbox, choice.reactionType, choice.reactionValue);
      setSimSandbox(nextSandbox);
    }
    if (!choice.nextId) {
      setSimEnded(true);
      setSimText((prev) => `${prev}\n\n[Conversation ended]`);
      return;
    }
    enterSimNode(choice.nextId, nextSandbox);
  }

  function continueSimulation(): void {
    if (!simFallbackNextId) {
      setSimEnded(true);
      setSimText((prev) => `${prev}\n\n[Conversation ended]`);
      return;
    }
    enterSimNode(simFallbackNextId);
  }

  async function handleSave(): Promise<void> {
    if (!activeType || parsedJsonError || validationIssues.length > 0) {
      if (validationIssues.length > 0) {
        setStatus(`Save blocked: ${validationIssues.length} validation issue(s).`);
      }
      return;
    }
    setIsSaving(true);
    setError("");
    setStatus(`Saving ${formatTypeLabel(activeType)}...`);
    try {
      const parsed = JSON.parse(jsonText) as JsonValue;
      const payload = activeType === "npcs"
        ? normalizeNpcPayloadForSave(parsed)
        : activeType === "images"
          ? normalizeImagesPayloadForSave(parsed)
        : activeType === "sprites"
          ? normalizeSpritePayloadForSave(parsed)
          : activeType === "tiles"
            ? normalizeTilesPayloadForSave(parsed)
            : parsed;
      const payloadText = JSON.stringify(payload, null, 2);
      setJsonText(payloadText);
      const res = await fetch(`/api/content/${activeType}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!res.ok) {
        const body = await res.text();
        throw new Error(`Save failed (${res.status}): ${body.slice(0, 180)}`);
      }
      setSavedContentJsonText(payloadText);
      setStatus(`Saved ${formatTypeLabel(activeType)}.`);
    } catch (err: unknown) {
      setError(String(err));
      setStatus(`Save failed for ${formatTypeLabel(activeType)}.`);
    } finally {
      setIsSaving(false);
    }
  }

  async function resolveDefaultWorldshaperStudioWorldId(): Promise<string> {
    const payload = await fetchJsonOrThrow<{ worldId?: string; world?: JsonObject }>("/api/world-default");
    const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim();
    return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
  }

  async function handleLaunchWorldshaperStudio(): Promise<void> {
    try {
      setError("");
      setStatus("Preparing Worldshaper Studio...");
      const nextWorldId = await resolveDefaultWorldshaperStudioWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
      const popup = openWorldshaperStudioWindow(nextWorldId, window, { worldId: nextWorldId });
      if (!popup) {
        setError("The browser blocked the Worldshaper Studio window.");
        setStatus("Worldshaper Studio unavailable: studio window was blocked.");
        return;
      }
      setStatus(`Opening Worldshaper Studio for ${nextWorldId}...`);
    } catch (err: unknown) {
      setError(String(err));
      setStatus("Worldshaper Studio unavailable: failed to prepare world data.");
    }
  }

  return (
    <div className="page-shell">
      <header className="header-card">
        <div className="header-copy">
          <p className="eyebrow">New RPG</p>
          <h1>Worldshaper</h1>
          <p className="lede">Worldbuilding studio with tabbed pages, structured editing, and raw JSON fallback.</p>
        </div>
        <button
          type="button"
          className="header-map-editor-btn"
          onClick={handleLaunchWorldshaperStudio}
          disabled={isLoading}
        >
          <span className="header-map-editor-btn-label">Worldshaper Studio</span>
        </button>
      </header>

      <section className="workspace-card">
        <TopNavTabs
          contentTypes={contentTypes}
          configTabLabels={configTabLabels}
          activeSection={activeSection}
          activeType={activeType}
          activeConfigTab={activeConfigTab}
          hasPendingRecordChanges={hasPendingRecordChanges}
          isLoading={isLoading}
          isSaving={isSaving}
          formatTypeLabel={formatTypeLabel}
          onError={setError}
          onSetActiveSection={setActiveSection}
            onSetActiveType={requestActiveType}
          onSetActiveConfigTab={setActiveConfigTab}
        />

        <EditorToolbar
          activeSection={activeSection}
          activeType={activeType}
          activeConfigTab={activeConfigTab}
          activeConfigEntriesLength={activeConfigEntries.length}
          selectedConfigIndex={selectedConfigIndex}
          selectedConfigEntry={selectedConfigEntry}
          hasUnsavedContentChanges={hasUnsavedContentChanges}
          hasUnsavedConfigChanges={hasUnsavedConfigChanges}
          parsedJsonError={parsedJsonError}
          validationIssuesLength={validationIssues.length}
          hasPendingRecordChanges={hasPendingRecordChanges}
          recordDraftError={recordDraftError}
          isLoading={isLoading}
          isSaving={isSaving}
          onSaveContent={handleSave}
          onAddRecord={addRecord}
          onDeleteRecord={deleteSelectedRecord}
          onSaveConfig={handleSaveConfig}
          onAddConfigEntry={addConfigEntry}
          onDeleteConfigEntry={deleteActiveConfigEntry}
          onMoveConfigEntry={moveConfigEntry}
          error={error}
        />

        {activeSection === "content" ? (
          <ContentSection
            ctx={{
              activeType,
              setActiveType,
              setActiveSection,
              records,
              selectedIndex: selectedRecordIndex,
              isAllRecordsSelected,
              hasPendingRecordChanges,
              getRecordLabel,
              buildSpritePreviewDataUrl,
              normalizeHexColor,
              selectRecordIndex,
              commitPendingRecordChanges,
              revertPendingRecordChanges,
              recordDraftError,
              isLoading,
              isSaving,
              selectedRecord,
              recordJsonDraft,
              jsonText,
              handleRawJsonEditorChange,
              activeEditTabs,
              activeEditTab,
              setActiveEditTabByType,
              selectedFieldEntriesForTab,
              isPlainObject,
              toFieldLabel,
              handlePrimitiveFieldChange,
              npcTownOptions,
              npcFactionOptions,
              handleNpcPositionFieldChange,
              npcSpriteSearchQuery,
              setNpcSpriteSearchQuery,
              filteredNpcSpriteOptions,
              npcSpriteOptions,
              allNpcRecords,
              allNpcTemplateRecords,
              contentDataByType,
              patchSelectedRecord,
              activeSpritePaintSymbol: resolvedActiveSpritePaintSymbol,
              setActiveSpritePaintSymbol,
              selectedSpriteEditorSize,
              getSpriteCellSymbol,
              getSpritePalette,
              paintSpriteCell,
              colorPaletteEntries: Array.isArray(catalogMeta.colors) ? catalogMeta.colors : [],
              addItemActionEntry,
              selectedItemActions,
              isStepCollapsed,
              setStepCollapsed,
              getItemActionSummary,
              moveItemActionEntry,
              deleteItemActionEntry,
              getPlainObjectArray,
              patchSelectedRecordArray,
              patchItemActionFlowSteps,
              createFlowStep,
              FLOW_KIND_LABELS,
              getFlowStepSummary,
              conditionTypeOptions,
              getDefaultConditionValue,
              renderConditionValueField,
              selectedQuestSteps,
              getQuestStepSummary,
              moveQuestStepEntry,
              deleteQuestStepEntry,
              patchQuestSteps,
              addQuestStepEntry,
              addDialogueNode,
              deleteDialogueNode,
              selectedDialogueNode,
              moveDialogueNode,
              selectedDialogueNodeIndex: activeDialogueNodeIndex,
              dialogueNodes,
              setSelectedDialogueNodeIndex,
              selectedDialogueFieldEntries,
              handleDialogueNodeFieldChange,
              patchFlowSteps,
              moveFlowStepByDirection,
              selectedFlowSteps,
              dropTargetStepId,
              setDraggingStepId,
              setDropTargetStepId,
              moveFlowStepById,
              dialogueNodeIds,
              DIALOGUE_REACTION_TYPES,
              simSandbox,
              simCurrentNodeId,
              simText,
              simChoices,
              simFallbackNextId,
              simEnded,
              startSimulation,
              updateSandbox,
              chooseSimChoice,
              continueSimulation,
              draggingStepId,
            }}
          />
        ) : (
          <ConfigSection
            activeConfigTab={activeConfigTab}
            activeConfigKey={activeConfigKey}
            activeConfigEntries={activeConfigEntries}
            selectedConfigIndex={selectedConfigIndex}
            selectedConfigEntry={selectedConfigEntry}
            rootKeys={Object.keys(ROOT_KEY_BY_TYPE)}
            typeLabels={TYPE_LABELS}
            getCatalogEntryIdValue={getCatalogEntryIdValue}
            normalizeStringList={normalizeStringList}
            parseCsv={parseCsv}
            getContentFieldKeysByType={getContentFieldKeysByType}
            onSetSelectedConfigIndex={(index) => setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: index }))}
            onUpdateActiveConfigEntry={updateActiveConfigEntry}
          />
        )}

        <StatusFooter
          status={status}
          validationIssues={visibleValidationIssues}
          parsedJsonError={parsedJsonError}
          recordDraftError={recordDraftError}
        />
      </section>
    </div>
  );
}

export default App;


