import { ConnectionGraphModel } from "../models/ConnectionGraphModel.js";
import { ConnectionOpinionModel } from "../models/ConnectionOpinionModel.js";
import { ConnectionCategories } from "./ConnectionCategories.js";
import { ConnectionCategoryPresets } from "./ConnectionCategoryPresets.js";
import { ConnectionChangeNotifier } from "./ConnectionChangeNotifier.js";
import { ConnectionDatabaseService } from "./ConnectionDatabaseService.js";
import { ConnectionTargetResolver } from "./ConnectionTargetResolver.js";

const MODULE_ID = "augur-nexus";
const PLAYER_VISIBILITY_SETTING_KEY = "playerConnectionVisibility";

export class ConnectionStore {
    static PLAYER_VISIBILITY_VALUES = new Set(["inherit", "show", "hide"]);
    static #graphCache = null;
    static #batchState = null;
    static #writeQueue = Promise.resolve();

    static getGraph() {
        const graph = this.#batchState?.graph || this.getGraphContext().graph;
        return foundry.utils.deepClone(graph);
    }

    static getGraphContext() {
        if (this.#batchState) {
            if (!this.#batchState.context) this.#batchState.context = this.#buildGraphContext(this.#batchState.graph);
            return this.#batchState.context;
        }

        if (this.#graphCache) return this.#graphCache;
        this.#graphCache = this.#buildGraphContext(ConnectionDatabaseService.getGraph());
        return this.#graphCache;
    }

    static invalidateCache() {
        this.#graphCache = null;
    }

    static async setGraph(graph, { reason = "update" } = {}) {
        if (this.#batchState && !this.#batchState.accepting) {
            return this.runBatch(() => this.setGraph(graph, { reason }), { reason });
        }
        if (this.#batchState?.accepting && graph === this.#batchState.graph) {
            this.#markReason(reason);
            return graph;
        }
        const candidate = ConnectionGraphModel.normalizeGraph(graph);
        if (this.#batchState?.accepting) {
            this.#replaceGraph(candidate);
            this.#markReason(reason);
            return this.#batchState.graph;
        }
        await this.runBatch(() => this.#replaceGraph(candidate), { reason });
        return this.getGraphContext().graph;
    }

    static async runBatch(callback, { reason = "batch" } = {}) {
        if (typeof callback !== "function") return undefined;
        if (this.#batchState?.accepting) return callback();

        const execute = async () => {
            this.#batchState = this.#createBatchState();
            let committed = false;
            try {
                const result = await callback();
                const batch = this.#batchState;
                batch.accepting = false;
                const reasons = [...batch.reasons];
                const notificationReason = reasons.length ? `${reason}:${reasons.join(",")}` : reason;
                if (batch.changed) {
                    const persisted = await ConnectionDatabaseService.applyChanges({
                        nodeUpserts: batch.nodeUpserts,
                        nodeDeletes: batch.nodeDeletes,
                        categoryUpserts: batch.categoryUpserts,
                        categoryDeletes: batch.categoryDeletes,
                        edgeUpserts: batch.edgeUpserts,
                        edgeDeletes: batch.edgeDeletes
                    }, {
                        reason: notificationReason
                    });
                    batch.graph.revision = persisted.revision;
                    batch.graph.updateId = persisted.updateId;
                    committed = true;
                }
                const nextContext = this.#finalizeBatchContext(batch);
                this.#batchState = null;
                this.#graphCache = nextContext;
                if (batch.changed) ConnectionChangeNotifier.notify(nextContext.graph, { reason: notificationReason });
                return result;
            } catch (error) {
                if (!committed) this.#rollbackBatch(this.#batchState);
                this.#batchState = null;
                this.#graphCache = null;
                throw error;
            }
        };

        const queued = this.#writeQueue.then(execute, execute);
        this.#writeQueue = queued.catch(() => undefined);
        return queued;
    }

    static getNode(nodeId) {
        if (this.#batchState) return this.#batchState.graph.nodes?.[nodeId] || null;
        return this.getGraphContext().graph.nodes?.[nodeId] || null;
    }

    static getConnectionEdge(edgeId) {
        if (this.#batchState) return this.#batchState.graph.edges?.[edgeId] || null;
        return this.getGraphContext().graph.edges?.[edgeId] || null;
    }

    static getConnectionsForNode(nodeId) {
        if (!nodeId) return [];
        const entries = this.getGraphContext().adjacency.get(nodeId) || [];
        return entries
            .filter(connection => this.canUserSeeConnection(connection.edge))
            .filter(connection => !!connection.node)
            .filter(connection => ConnectionTargetResolver.canUserSeeNode(connection.node, game.user));
    }

    static getConnectionView(edge, nodeId, customCategories = this.getCustomCategories()) {
        const category = ConnectionCategories.normalize(
            this.#hasViewValue(edge, nodeId, "category") ? edge.views[nodeId].category : edge?.category,
            edge?.category || "npc",
            customCategories
        );
        const hasViewCategory = this.#hasViewValue(edge, nodeId, "category");
        const role = this.#hasViewValue(edge, nodeId, "role")
            ? String(edge.views[nodeId].role || "")
            : String(hasViewCategory ? ConnectionCategories.roleFor(category) : (edge?.role || ConnectionCategories.roleFor(category)));
        const note = this.#hasViewValue(edge, nodeId, "note")
            ? String(edge.views[nodeId].note || "")
            : String(edge?.note || "");
        const sort = this.#hasViewValue(edge, nodeId, "sort")
            ? Number(edge.views[nodeId].sort || 0)
            : Number(edge?.sort || 100);
        return {
            category,
            role,
            note,
            sort: Number.isFinite(sort) ? sort : 100
        };
    }

    static getConnectionCategoryForNode(edge, nodeId, customCategories = this.getCustomCategories()) {
        return this.getConnectionView(edge, nodeId, customCategories).category;
    }

    static getConnectionRoleForNode(edge, nodeId, customCategories = this.getCustomCategories()) {
        const view = this.getConnectionView(edge, nodeId, customCategories);
        return view.role || ConnectionCategories.roleFor(view.category);
    }

    static getConnectionNoteForNode(edge, nodeId, customCategories = this.getCustomCategories()) {
        return this.getConnectionView(edge, nodeId, customCategories).note;
    }

    static getConnectionSortForNode(edge, nodeId, customCategories = this.getCustomCategories()) {
        return this.getConnectionView(edge, nodeId, customCategories).sort;
    }

    static getGlobalPlayerConnectionVisibilityPolicy() {
        const policy = game.settings.get(MODULE_ID, PLAYER_VISIBILITY_SETTING_KEY) || "all";
        return ["all", "explicit"].includes(policy) ? policy : "all";
    }

    static getGlobalPlayerConnectionVisibilityPolicyLabel(policy = this.getGlobalPlayerConnectionVisibilityPolicy()) {
        if (policy === "explicit") return "Explicit Only";
        return "All Connections";
    }

    static normalizePlayerVisibility(value = "inherit") {
        return this.PLAYER_VISIBILITY_VALUES.has(value) ? value : "inherit";
    }

    static getConnectionPlayerVisibility(edge) {
        return this.normalizePlayerVisibility(edge?.playerVisibility);
    }

    static getConnectionPlayerVisibilityLabel(edgeOrValue = "inherit") {
        const normalized = typeof edgeOrValue === "string"
            ? this.normalizePlayerVisibility(edgeOrValue)
            : this.getConnectionPlayerVisibility(edgeOrValue);
        if (normalized === "show") return "Yes";
        if (normalized === "hide") return "No";
        return "Global";
    }

    static getConnectionPlayerVisibilityIcon(edgeOrValue = "inherit") {
        const normalized = typeof edgeOrValue === "string"
            ? this.normalizePlayerVisibility(edgeOrValue)
            : this.getConnectionPlayerVisibility(edgeOrValue);
        if (normalized === "show") return "fas fa-eye";
        if (normalized === "hide") return "fas fa-eye-slash";
        return "fas fa-layer-group";
    }

    static isConnectionVisibleToPlayers(edge) {
        const normalized = this.getConnectionPlayerVisibility(edge);
        if (normalized === "show") return true;
        if (normalized === "hide") return false;
        return this.getGlobalPlayerConnectionVisibilityPolicy() === "all";
    }

    static canUserSeeConnection(edge, user = game.user) {
        if (user?.isGM) return true;
        return this.isConnectionVisibleToPlayers(edge);
    }

    static async setGlobalPlayerConnectionVisibilityPolicy(policy = "all") {
        if (!game.user.isGM) return this.getGlobalPlayerConnectionVisibilityPolicy();
        const normalized = ["explicit", "all"].includes(policy) ? policy : "all";
        await game.settings.set(MODULE_ID, PLAYER_VISIBILITY_SETTING_KEY, normalized);
        return normalized;
    }

    static async setConnectionPlayerVisibility(edgeId, value = "inherit") {
        if (!game.user.isGM) return null;
        const normalized = this.normalizePlayerVisibility(value);
        return this.updateConnection(edgeId, { playerVisibility: normalized });
    }

    static getCustomCategories() {
        return this.getGraphContext().customCategories;
    }

    static async addConnection(sourceTarget, relatedTarget, options = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.addConnection(sourceTarget, relatedTarget, options), { reason: "addConnection" });
        }
        const graph = this.#getMutableGraph();
        const sourceNode = ConnectionGraphModel.normalizeNode(sourceTarget, graph.customCategories);
        const targetNode = ConnectionGraphModel.normalizeNode(relatedTarget, graph.customCategories);
        if (!sourceNode || !targetNode || sourceNode.id === targetNode.id) return null;

        const adjacency = this.getGraphContext().adjacency;
        const sourceConnections = adjacency.get(sourceNode.id) || [];
        const targetConnections = adjacency.get(targetNode.id) || [];
        const existing = sourceConnections.find(connection => connection.node?.id === targetNode.id)?.edge || null;
        const sourceSort = this.#nextSortForConnections(sourceConnections, sourceNode.id, graph.customCategories);
        const targetSort = this.#nextSortForConnections(targetConnections, targetNode.id, graph.customCategories);

        const currentSourceNode = graph.nodes[sourceNode.id] || null;
        const currentTargetNode = graph.nodes[targetNode.id] || null;
        graph.nodes[sourceNode.id] = {
            ...(currentSourceNode || {}),
            ...sourceNode,
            createdTime: currentSourceNode?.createdTime || sourceNode.createdTime,
            updatedTime: Date.now()
        };
        graph.nodes[targetNode.id] = {
            ...(currentTargetNode || {}),
            ...targetNode,
            createdTime: currentTargetNode?.createdTime || targetNode.createdTime,
            updatedTime: Date.now()
        };

        const sourceCategory = ConnectionCategories.normalize(
            options.category || options.sourceView?.category || ConnectionCategoryPresets.defaultCategoryForConnection(sourceNode, targetNode, graph.customCategories),
            "npc",
            graph.customCategories
        );
        const targetCategory = ConnectionCategories.normalize(
            options.targetView?.category || ConnectionCategoryPresets.defaultCategoryForConnection(targetNode, sourceNode, graph.customCategories),
            "npc",
            graph.customCategories
        );
        if (existing) return existing;

        const sourceView = this.#normalizeViewOptions(options.sourceView || {}, {
            category: sourceCategory,
            role: options.role || ConnectionCategories.roleFor(sourceCategory),
            sort: sourceSort
        }, graph.customCategories);
        const targetView = this.#normalizeViewOptions(options.targetView || {}, {
            category: targetCategory,
            role: ConnectionCategories.roleFor(targetCategory),
            sort: targetSort
        }, graph.customCategories);

        const shouldTrackOpinions = options.trackOpinions !== false
            && (options.trackOpinions === true || ConnectionOpinionModel.shouldTrackByDefault(sourceNode, targetNode));
        const opinions = Object.hasOwn(options, "opinions")
            ? options.opinions
            : shouldTrackOpinions ? ConnectionOpinionModel.create() : null;

        const edge = ConnectionGraphModel.normalizeEdge({
            id: foundry.utils.randomID(),
            sourceNodeId: sourceNode.id,
            targetNodeId: targetNode.id,
            relationType: options.relationType || "",
            resourceFlows: options.resourceFlows || [],
            category: sourceView.category,
            role: sourceView.role || ConnectionCategories.roleFor(sourceView.category),
            note: options.note || "",
            sort: sourceView.sort,
            views: {
                [sourceNode.id]: sourceView,
                [targetNode.id]: targetView
            },
            ...(options.factionMembership ? { factionMembership: options.factionMembership } : {}),
            ...(options.factionSeat ? { factionSeat: options.factionSeat } : {}),
            ...(options.locationLeadership ? { locationLeadership: options.locationLeadership } : {}),
            ...(options.shipCaptain ? { shipCaptain: options.shipCaptain } : {}),
            ...(options.shipOwnership ? { shipOwnership: options.shipOwnership } : {}),
            ...(options.shipLocation ? { shipLocation: options.shipLocation } : {}),
            ...(opinions ? { opinions } : {}),
            createdTime: Date.now(),
            updatedTime: Date.now()
        }, graph.nodes, graph.customCategories);
        if (!edge) return null;

        graph.edges[edge.id] = edge;
        await this.setGraph(graph, { reason: "addConnection" });
        return edge;
    }

    static async updateConnection(edgeId, patch = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.updateConnection(edgeId, patch), { reason: "updateConnection" });
        }
        const graph = this.#getMutableGraph();
        const current = graph.edges?.[edgeId];
        if (!current) return null;
        const next = ConnectionGraphModel.normalizeEdge({
            ...current,
            ...patch,
            category: patch.category ? ConnectionCategories.normalize(patch.category, current.category, graph.customCategories) : current.category,
            updatedTime: Date.now()
        }, graph.nodes, graph.customCategories);
        if (!next) return null;
        graph.edges[next.id] = next;
        await this.setGraph(graph, { reason: "updateConnection" });
        return next;
    }

    static async setConnectionViewForNode(edgeId, nodeId, patch = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.setConnectionViewForNode(edgeId, nodeId, patch), { reason: "setConnectionView" });
        }
        const graph = this.#getMutableGraph();
        const current = graph.edges?.[edgeId];
        if (!current || !nodeId || (current.sourceNodeId !== nodeId && current.targetNodeId !== nodeId)) return null;
        const currentView = this.getConnectionView(current, nodeId, graph.customCategories);
        const nextView = { ...currentView };
        if (Object.hasOwn(patch, "category")) {
            nextView.category = ConnectionCategories.normalize(patch.category, currentView.category, graph.customCategories);
            if (!Object.hasOwn(patch, "role")) nextView.role = ConnectionCategories.roleFor(nextView.category);
        }
        if (Object.hasOwn(patch, "role")) nextView.role = String(patch.role || "");
        if (Object.hasOwn(patch, "note")) nextView.note = String(patch.note || "");
        if (Object.hasOwn(patch, "sort")) {
            const sort = Number(patch.sort);
            if (Number.isFinite(sort)) nextView.sort = sort;
        }

        const next = ConnectionGraphModel.normalizeEdge({
            ...current,
            views: {
                ...(current.views || {}),
                [nodeId]: nextView
            },
            updatedTime: Date.now()
        }, graph.nodes, graph.customCategories);
        if (!next) return null;
        graph.edges[next.id] = next;
        await this.setGraph(graph, { reason: "setConnectionView" });
        return next;
    }

    static async upsertCustomCategory(category = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.upsertCustomCategory(category), { reason: "upsertCustomCategory" });
        }
        const graph = this.#getMutableGraph();
        const label = String(category.label || "").trim();
        if (!label) return null;

        let id = String(category.id || "").trim() || ConnectionCategories.makeCustomCategoryId(label);
        const baseId = id;
        let suffix = 2;
        while ((graph.customCategories?.[id] || ConnectionCategories.isProtected(id)) && id !== category.id) {
            id = `${baseId}-${suffix}`;
            suffix += 1;
        }

        const now = Date.now();
        const normalized = ConnectionCategories.normalizeCustomCategories({
            [id]: {
                ...category,
                id,
                label,
                singular: category.singular || label,
                icon: category.icon || "fas fa-tag",
                color: category.color || "#c9a7ff",
                createdTime: category.createdTime || now,
                updatedTime: now
            }
        })[id];
        if (!normalized) return null;

        graph.customCategories[id] = normalized;
        await this.setGraph(graph, { reason: "upsertCustomCategory" });
        return normalized;
    }

    static async deleteCustomCategory(categoryId) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.deleteCustomCategory(categoryId), { reason: "deleteCustomCategory" });
        }
        const graph = this.#getMutableGraph();
        if (!categoryId || !graph.customCategories?.[categoryId]) return false;
        delete graph.customCategories[categoryId];
        for (const node of Object.values(graph.nodes || {})) {
            const next = { ...node };
            let changed = false;
            if (next.category === categoryId) {
                next.category = "unassigned";
                changed = true;
            }
            if (Array.isArray(next.groupOrder) && next.groupOrder.includes(categoryId)) {
                next.groupOrder = next.groupOrder.filter(id => id !== categoryId);
                if (!next.groupOrder.length) delete next.groupOrder;
                changed = true;
            }
            if (Array.isArray(next.hiddenCategoryIds) && next.hiddenCategoryIds.includes(categoryId)) {
                next.hiddenCategoryIds = next.hiddenCategoryIds.filter(id => id !== categoryId);
                if (!next.hiddenCategoryIds.length) delete next.hiddenCategoryIds;
                changed = true;
            }
            if (changed) graph.nodes[node.id] = { ...next, updatedTime: Date.now() };
        }
        for (const edge of Object.values(graph.edges || {})) {
            const next = {
                ...edge,
                views: Object.fromEntries(
                    Object.entries(edge.views || {}).map(([nodeId, view]) => [nodeId, { ...view }])
                )
            };
            let changed = false;
            if (next.category === categoryId) {
                next.category = "unassigned";
                next.role = ConnectionCategories.roleFor("unassigned");
                changed = true;
            }
            for (const view of Object.values(next.views || {})) {
                if (view.category !== categoryId) continue;
                view.category = "unassigned";
                view.role = ConnectionCategories.roleFor("unassigned");
                changed = true;
            }
            if (changed) graph.edges[edge.id] = { ...next, updatedTime: Date.now() };
        }
        await this.setGraph(graph, { reason: "deleteCustomCategory" });
        return true;
    }

    static getGroupOrderForNode(nodeId) {
        const node = this.getGraphContext().graph.nodes?.[nodeId] || null;
        return Array.isArray(node?.groupOrder) ? [...node.groupOrder] : [];
    }

    static getHiddenCategoryIdsForNode(nodeId) {
        const node = this.getGraphContext().graph.nodes?.[nodeId] || null;
        return Array.isArray(node?.hiddenCategoryIds) ? [...node.hiddenCategoryIds] : [];
    }

    static isCategoryGroupHiddenForNode(nodeId, categoryId) {
        if (!nodeId || !categoryId) return false;
        return this.getHiddenCategoryIdsForNode(nodeId).includes(categoryId);
    }

    static canUserSeeCategoryGroup(nodeId, categoryId, user = game.user) {
        if (user?.isGM) return true;
        return !this.isCategoryGroupHiddenForNode(nodeId, categoryId);
    }

    static async setCategoryGroupHiddenForNode(nodeId, categoryId, hidden = true) {
        if (!game.user.isGM) return false;
        if (!this.#batchState?.accepting) {
            return this.runBatch(
                () => this.setCategoryGroupHiddenForNode(nodeId, categoryId, hidden),
                { reason: "setGroupVisibility" }
            );
        }
        const graph = this.#getMutableGraph();
        const node = graph.nodes?.[nodeId] || null;
        if (!node || !categoryId) return false;

        const categories = ConnectionCategories.editable(graph.customCategories);
        const knownIds = new Set(categories.map(category => category.id));
        if (!knownIds.has(categoryId)) return false;

        const hiddenIds = new Set(Array.isArray(node.hiddenCategoryIds) ? node.hiddenCategoryIds.filter(id => knownIds.has(id)) : []);
        if (hidden) hiddenIds.add(categoryId);
        else hiddenIds.delete(categoryId);

        graph.nodes[nodeId] = {
            ...node,
            hiddenCategoryIds: [...hiddenIds],
            updatedTime: Date.now()
        };
        if (!graph.nodes[nodeId].hiddenCategoryIds.length) delete graph.nodes[nodeId].hiddenCategoryIds;

        await this.setGraph(graph, { reason: "setGroupVisibility" });
        return true;
    }

    static async toggleCategoryGroupHiddenForNode(nodeId, categoryId) {
        const hidden = this.isCategoryGroupHiddenForNode(nodeId, categoryId);
        return this.setCategoryGroupHiddenForNode(nodeId, categoryId, !hidden);
    }

    static async moveGroupForNode(nodeId, categoryId, direction = 0, { categoryIds = null } = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(
                () => this.moveGroupForNode(nodeId, categoryId, direction, { categoryIds }),
                { reason: "moveGroup" }
            );
        }
        const graph = this.#getMutableGraph();
        const node = graph.nodes?.[nodeId] || null;
        const step = Math.sign(Number(direction || 0));
        if (!node || !categoryId || !step) return false;

        const categories = ConnectionCategories.editable(graph.customCategories);
        const allCategoryIds = categories.map(category => category.id);
        if (!allCategoryIds.includes(categoryId)) return false;

        const currentOrder = Array.isArray(node.groupOrder) ? node.groupOrder : [];
        const orderedIds = [
            ...currentOrder.filter(id => allCategoryIds.includes(id)),
            ...allCategoryIds.filter(id => !currentOrder.includes(id))
        ];

        const movableIds = (Array.isArray(categoryIds) ? categoryIds : orderedIds)
            .map(id => String(id || "").trim())
            .filter(id => orderedIds.includes(id));
        const movableIndex = movableIds.indexOf(categoryId);
        const nextMovableIndex = movableIndex + step;
        if (movableIndex < 0 || nextMovableIndex < 0 || nextMovableIndex >= movableIds.length) return false;

        const neighborId = movableIds[nextMovableIndex];
        const index = orderedIds.indexOf(categoryId);
        const neighborIndex = orderedIds.indexOf(neighborId);
        if (index < 0 || neighborIndex < 0) return false;

        orderedIds.splice(index, 1);
        const adjustedNeighborIndex = orderedIds.indexOf(neighborId);
        orderedIds.splice(step < 0 ? adjustedNeighborIndex : adjustedNeighborIndex + 1, 0, categoryId);

        const defaultOrder = allCategoryIds.join("|");
        const nextOrder = orderedIds.join("|") === defaultOrder ? [] : orderedIds;
        graph.nodes[nodeId] = {
            ...node,
            groupOrder: nextOrder,
            updatedTime: Date.now()
        };
        if (!nextOrder.length) delete graph.nodes[nodeId].groupOrder;

        await this.setGraph(graph, { reason: "moveGroup" });
        return true;
    }

    static async removeConnection(edgeId) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.removeConnection(edgeId), { reason: "removeConnection" });
        }
        const graph = this.#getMutableGraph();
        if (!graph.edges?.[edgeId]) return false;
        delete graph.edges[edgeId];
        await this.setGraph(graph, { reason: "removeConnection" });
        return true;
    }

    static async removeConnections(edgeIds = [], { reason = "removeConnections" } = {}) {
        const ids = [...new Set((edgeIds || []).map(String).filter(Boolean))];
        if (!ids.length) return 0;
        return this.runBatch(async () => {
            let removed = 0;
            for (const edgeId of ids) {
                if (await this.removeConnection(edgeId)) removed += 1;
            }
            return removed;
        }, { reason });
    }

    static async reorderConnectionForNode(edgeId, nodeId, beforeEdgeId = null) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(
                () => this.reorderConnectionForNode(edgeId, nodeId, beforeEdgeId),
                { reason: "reorderConnection" }
            );
        }
        const graph = this.#getMutableGraph();
        if (!graph.edges?.[edgeId] || !nodeId) return false;
        const edges = (this.getGraphContext().adjacency.get(nodeId) || [])
            .map(connection => connection.edge)
            .sort((a, b) => this.getConnectionSortForNode(a, nodeId, graph.customCategories) - this.getConnectionSortForNode(b, nodeId, graph.customCategories) || String(a.id).localeCompare(String(b.id)));
        const movingIndex = edges.findIndex(edge => edge.id === edgeId);
        if (movingIndex < 0) return false;

        const [moving] = edges.splice(movingIndex, 1);
        const targetIndex = beforeEdgeId ? edges.findIndex(edge => edge.id === beforeEdgeId) : -1;
        edges.splice(targetIndex >= 0 ? targetIndex : edges.length, 0, moving);

        edges.forEach((edge, index) => {
            graph.edges[edge.id] = ConnectionGraphModel.normalizeEdge({
                ...edge,
                views: {
                    ...(edge.views || {}),
                    [nodeId]: {
                        ...this.getConnectionView(edge, nodeId, graph.customCategories),
                        sort: (index + 1) * 100
                    }
                },
                updatedTime: Date.now()
            }, graph.nodes, graph.customCategories);
        });
        await this.setGraph(graph, { reason: "reorderConnection" });
        return true;
    }

    static async removeNode(nodeId, { removeEdges = true } = {}) {
        if (!this.#batchState?.accepting) {
            return this.runBatch(() => this.removeNode(nodeId, { removeEdges }), { reason: "removeNode" });
        }
        const graph = this.#getMutableGraph();
        if (!graph.nodes?.[nodeId]) return false;
        const incidentEdgeIds = (this.getGraphContext().adjacency.get(nodeId) || [])
            .map(connection => connection.edge.id);
        delete graph.nodes[nodeId];
        for (const incidentEdgeId of incidentEdgeIds) delete graph.edges[incidentEdgeId];
        await this.setGraph(graph, { reason: "removeNode" });
        return true;
    }

    static async removeNodes(nodeIds = [], { removeEdges = true, reason = "removeNodes" } = {}) {
        const ids = [...new Set((nodeIds || []).map(String).filter(Boolean))];
        if (!ids.length) return 0;
        return this.runBatch(async () => {
            let removed = 0;
            for (const nodeId of ids) {
                if (await this.removeNode(nodeId, { removeEdges })) removed += 1;
            }
            return removed;
        }, { reason });
    }

    static async removeConnectionsForTarget(target) {
        const nodeId = ConnectionTargetResolver.getNodeId(target);
        return this.removeNode(nodeId, { removeEdges: true });
    }

    static #createBatchState() {
        const baseContext = this.getGraphContext();
        const baseGraph = baseContext.graph;
        const batch = {
            graph: null,
            context: baseContext,
            baseContext,
            accepting: true,
            changed: false,
            reasons: new Set(),
            nodeUpserts: new Map(),
            nodeDeletes: new Set(),
            categoryUpserts: new Map(),
            categoryDeletes: new Set(),
            edgeUpserts: new Map(),
            edgeDeletes: new Set(),
            originalNodes: new Map(),
            originalCategories: new Map(),
            originalEdges: new Map(),
            nodeTarget: baseGraph.nodes || {},
            categoryTarget: baseGraph.customCategories || {},
            edgeTarget: baseGraph.edges || {}
        };
        batch.graph = {
            ...baseGraph,
            nodes: this.#createRecordProxy(batch, "node", batch.nodeTarget),
            customCategories: this.#createRecordProxy(batch, "category", batch.categoryTarget),
            edges: this.#createRecordProxy(batch, "edge", batch.edgeTarget)
        };
        return batch;
    }

    static #getMutableGraph() {
        if (!this.#batchState?.accepting) throw new Error("A connection mutation requires an active transaction.");
        return this.#batchState.graph;
    }

    static #createRecordProxy(batch, kind, target) {
        return new Proxy(target, {
            set: (records, property, value) => {
                if (typeof property === "symbol") return Reflect.set(records, property, value);
                const id = String(property);
                if (Object.hasOwn(records, id) && this.#recordsEqual(records[id], value)) return true;
                this.#rememberOriginal(batch, kind, id, records);
                records[id] = value;
                this.#recordMutation(batch, kind, id, value, false);
                return true;
            },
            deleteProperty: (records, property) => {
                if (typeof property === "symbol") return Reflect.deleteProperty(records, property);
                const id = String(property);
                if (!Object.hasOwn(records, id)) return true;
                this.#rememberOriginal(batch, kind, id, records);
                delete records[id];
                this.#recordMutation(batch, kind, id, null, true);
                return true;
            }
        });
    }

    static #rememberOriginal(batch, kind, id, records) {
        const originals = kind === "node"
            ? batch.originalNodes
            : kind === "category"
                ? batch.originalCategories
                : batch.originalEdges;
        if (originals.has(id)) return;
        originals.set(id, {
            exists: Object.hasOwn(records, id),
            value: records[id]
        });
    }

    static #recordMutation(batch, kind, id, value, deleted) {
        batch.changed = true;
        batch.context = null;
        if (kind === "node") {
            if (deleted) {
                batch.nodeUpserts.delete(id);
                batch.nodeDeletes.add(id);
            } else {
                batch.nodeDeletes.delete(id);
                batch.nodeUpserts.set(id, value);
            }
            return;
        }
        if (kind === "category") {
            if (deleted) {
                batch.categoryUpserts.delete(id);
                batch.categoryDeletes.add(id);
            } else {
                batch.categoryDeletes.delete(id);
                batch.categoryUpserts.set(id, value);
            }
            return;
        }
        if (deleted) {
            batch.edgeUpserts.delete(id);
            batch.edgeDeletes.add(id);
        } else {
            batch.edgeDeletes.delete(id);
            batch.edgeUpserts.set(id, value);
        }
    }

    static #replaceGraph(candidate) {
        const batch = this.#batchState;
        if (!batch) return;
        this.#replaceRecordMap(batch.graph.customCategories, candidate.customCategories || {});
        this.#replaceRecordMap(batch.graph.nodes, candidate.nodes || {});
        this.#replaceRecordMap(batch.graph.edges, candidate.edges || {});
    }

    static #replaceRecordMap(current, candidate) {
        for (const id of Object.keys(current)) {
            if (!Object.hasOwn(candidate, id)) delete current[id];
        }
        for (const [id, value] of Object.entries(candidate)) {
            if (!Object.hasOwn(current, id) || !this.#recordsEqual(current[id], value)) current[id] = value;
        }
    }

    static #rollbackBatch(batch) {
        if (!batch) return;
        this.#restoreRecords(batch.nodeTarget, batch.originalNodes);
        this.#restoreRecords(batch.categoryTarget, batch.originalCategories);
        this.#restoreRecords(batch.edgeTarget, batch.originalEdges);
    }

    static #finalizeBatchContext(batch) {
        if (!batch?.changed) return batch?.baseContext || null;
        if (batch.context) {
            batch.context.graph.revision = batch.graph.revision;
            batch.context.graph.updateId = batch.graph.updateId;
            batch.context.signature = `${batch.graph.revision || 0}:${batch.graph.updateId || ""}`;
            return batch.context;
        }

        const context = batch.baseContext;
        if (!context) return null;
        const affectedNodeIds = new Set(batch.originalNodes.keys());

        for (const [edgeId, original] of batch.originalEdges) {
            if (!original.exists) continue;
            affectedNodeIds.add(original.value.sourceNodeId);
            affectedNodeIds.add(original.value.targetNodeId);
            this.#removeEdgeFromAdjacency(context.adjacency, original.value.sourceNodeId, edgeId);
            this.#removeEdgeFromAdjacency(context.adjacency, original.value.targetNodeId, edgeId);
        }

        for (const [edgeId, edge] of batch.edgeUpserts) {
            const sourceNode = batch.graph.nodes?.[edge.sourceNodeId] || null;
            const targetNode = batch.graph.nodes?.[edge.targetNodeId] || null;
            if (!sourceNode || !targetNode) continue;
            affectedNodeIds.add(edge.sourceNodeId);
            affectedNodeIds.add(edge.targetNodeId);
            const sourceBucket = context.adjacency.get(edge.sourceNodeId) || [];
            sourceBucket.push({ edge, node: targetNode });
            context.adjacency.set(edge.sourceNodeId, sourceBucket);
            const targetBucket = context.adjacency.get(edge.targetNodeId) || [];
            targetBucket.push({ edge, node: sourceNode });
            context.adjacency.set(edge.targetNodeId, targetBucket);
        }

        for (const nodeId of batch.originalNodes.keys()) {
            const node = batch.graph.nodes?.[nodeId] || null;
            if (!node) {
                context.adjacency.delete(nodeId);
                continue;
            }
            for (const connection of context.adjacency.get(nodeId) || []) {
                const edge = connection.edge;
                const relatedNodeId = edge.sourceNodeId === nodeId ? edge.targetNodeId : edge.sourceNodeId;
                const relatedBucket = context.adjacency.get(relatedNodeId) || [];
                const reverseConnection = relatedBucket.find(candidate => candidate.edge.id === edge.id);
                if (reverseConnection) reverseConnection.node = node;
                affectedNodeIds.add(relatedNodeId);
            }
        }

        for (const nodeId of affectedNodeIds) {
            const bucket = context.adjacency.get(nodeId);
            if (!bucket) continue;
            bucket.sort((a, b) =>
                this.getConnectionSortForNode(a.edge, nodeId, batch.graph.customCategories)
                - this.getConnectionSortForNode(b.edge, nodeId, batch.graph.customCategories)
                || String(a.edge.id).localeCompare(String(b.edge.id))
            );
        }

        context.graph.revision = batch.graph.revision;
        context.graph.updateId = batch.graph.updateId;
        context.signature = `${batch.graph.revision || 0}:${batch.graph.updateId || ""}`;
        return context;
    }

    static #removeEdgeFromAdjacency(adjacency, nodeId, edgeId) {
        const bucket = adjacency.get(nodeId);
        if (!bucket) return;
        const filtered = bucket.filter(connection => connection.edge.id !== edgeId);
        if (filtered.length) adjacency.set(nodeId, filtered);
        else adjacency.delete(nodeId);
    }

    static #restoreRecords(target, originals) {
        for (const [id, original] of originals) {
            if (original.exists) target[id] = original.value;
            else delete target[id];
        }
    }

    static #markReason(reason) {
        if (!this.#batchState) return;
        this.#batchState.reasons.add(String(reason || "update"));
    }

    static #recordsEqual(left, right) {
        return this.#stableSerialize(left) === this.#stableSerialize(right);
    }

    static #stableSerialize(value, key = "") {
        if (key === "updatedTime" || key === "updateId") return "";
        if (Array.isArray(value)) {
            return `[${value.map(entry => this.#stableSerialize(entry)).join(",")}]`;
        }
        if (value && typeof value === "object") {
            return `{${Object.keys(value)
                .filter(entryKey => entryKey !== "updatedTime" && entryKey !== "updateId")
                .sort()
                .map(entryKey => `${JSON.stringify(entryKey)}:${this.#stableSerialize(value[entryKey], entryKey)}`)
                .join(",")}}`;
        }
        return JSON.stringify(value);
    }

    static #buildGraphContext(graph) {
        const normalized = ConnectionGraphModel.normalizeGraph(graph || {});
        const adjacency = new Map();
        const customCategories = normalized.customCategories || {};

        const pushConnection = (nodeId, edge, relatedNode) => {
            if (!nodeId || !relatedNode) return;
            const bucket = adjacency.get(nodeId) || [];
            bucket.push({ edge, node: relatedNode });
            adjacency.set(nodeId, bucket);
        };

        for (const edge of Object.values(normalized.edges || {})) {
            const sourceNode = normalized.nodes?.[edge.sourceNodeId] || null;
            const targetNode = normalized.nodes?.[edge.targetNodeId] || null;
            if (!sourceNode || !targetNode) continue;
            pushConnection(edge.sourceNodeId, edge, targetNode);
            pushConnection(edge.targetNodeId, edge, sourceNode);
        }

        for (const [nodeId, connections] of adjacency.entries()) {
            connections.sort((a, b) =>
                this.getConnectionSortForNode(a.edge, nodeId, customCategories)
                - this.getConnectionSortForNode(b.edge, nodeId, customCategories)
                || String(a.edge.id).localeCompare(String(b.edge.id))
            );
        }

        return {
            signature: `${normalized.revision || 0}:${normalized.updateId || ""}`,
            graph: normalized,
            customCategories,
            nodes: normalized.nodes || {},
            adjacency
        };
    }

    static #nextSortForConnections(connections, nodeId, customCategories) {
        const sorts = connections.map(connection =>
            this.getConnectionSortForNode(connection.edge, nodeId, customCategories)
        );
        return sorts.length ? Math.max(...sorts) + 100 : 100;
    }

    static #normalizeViewOptions(view = {}, fallback = {}, customCategories = {}) {
        const category = ConnectionCategories.normalize(view.category || fallback.category, fallback.category || "npc", customCategories);
        const sort = Number(Object.hasOwn(view, "sort") ? view.sort : fallback.sort);
        return {
            category,
            role: String(Object.hasOwn(view, "role") ? view.role || "" : fallback.role || ConnectionCategories.roleFor(category)),
            note: String(Object.hasOwn(view, "note") ? view.note || "" : fallback.note || ""),
            sort: Number.isFinite(sort) ? sort : 100
        };
    }

    static #hasViewValue(edge, nodeId, key) {
        return !!nodeId && !!edge?.views?.[nodeId] && Object.hasOwn(edge.views[nodeId], key);
    }
}
