import { handlesGridChange } from './events/onGridChange.js';
import { handlesGridResize } from './events/onGridResize.js';
import { handlesSetGrid } from './events/onSetGrid.js';
import GridConnections from './gridConnections.js';
import { array, move } from './dataHelper.js';
import { Color } from './primitives.js';
import TileData from './tile.js';
const NEIGHBOR_OFFSETS = [
    { x: -1, y: 0 },
    { x: 1, y: 0 },
    { x: 0, y: -1 },
    { x: 0, y: 1 },
];
export default class GridData {
    /**
     * Create a new grid with tiles, connections, symbols and rules.
     * @param width The width of the grid.
     * @param height The height of the grid.
     * @param tiles The tiles of the grid.
     * @param connections The connections of the grid, which determines which tiles are merged.
     * @param symbols The symbols in the grid.
     * @param rules The rules of the grid.
     */
    constructor(width, height, tiles, connections, symbols, rules) {
        Object.defineProperty(this, "width", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: width
        });
        Object.defineProperty(this, "height", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: height
        });
        Object.defineProperty(this, "tiles", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: void 0
        });
        Object.defineProperty(this, "connections", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: void 0
        });
        Object.defineProperty(this, "symbols", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: void 0
        });
        Object.defineProperty(this, "rules", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: void 0
        });
        this.width = width;
        this.height = height;
        this.tiles = tiles ?? array(width, height, () => TileData.empty());
        this.connections = connections ?? new GridConnections();
        const newSymbols = symbols
            ? GridData.deduplicateSymbols(symbols)
            : new Map();
        // do not deduplicate all rules because it makes for bad editor experience
        const newRules = rules ? GridData.deduplicateSingletonRules(rules) : [];
        this.symbols = newSymbols;
        this.rules = newRules;
        newSymbols.forEach(list => {
            list.forEach((sym, i) => {
                if (handlesGridChange(sym)) {
                    list[i] = sym.onGridChange(this);
                }
            });
        });
        newRules.forEach((rule, i) => {
            if (handlesGridChange(rule)) {
                newRules[i] = rule.onGridChange(this);
            }
        });
    }
    /**
     * Copy the current grid while modifying the provided properties.
     * @param param0 The properties to modify.
     * @returns The new grid with the modified properties.
     */
    copyWith({ width, height, tiles, connections, symbols, rules, }) {
        return new GridData(width ?? this.width, height ?? this.height, tiles ?? this.tiles, connections ?? this.connections, symbols ?? this.symbols, rules ?? this.rules);
    }
    isPositionValid(x, y) {
        return x >= 0 && x < this.width && y >= 0 && y < this.height;
    }
    /**
     * Safely get the tile at the given position.
     * @param x The x-coordinate of the tile.
     * @param y The y-coordinate of the tile.
     * @returns The tile at the given position, or a non-existent tile if the position is invalid.
     */
    getTile(x, y) {
        if (!this.isPositionValid(x, y))
            return TileData.doesNotExist();
        return this.tiles[y][x];
    }
    /**
     * Safely set the tile at the given position.
     * If the position is invalid, the grid is returned unchanged.
     * If the tile is merged with other tiles, the colors of all connected tiles are changed.
     *
     * @param x The x-coordinate of the tile.
     * @param y The y-coordinate of the tile.
     * @param tile The new tile to set.
     * @returns The new grid with the tile set at the given position.
     */
    setTile(x, y, tile) {
        if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
            return this;
        }
        const changing = this.connections.getConnectedTiles({ x, y });
        const tiles = this.tiles.map(row => [...row]);
        const newTile = typeof tile === 'function' ? tile(tiles[y][x]) : tile;
        changing.forEach(({ x, y }) => {
            tiles[y][x] = tiles[y][x].withColor(newTile.color);
        });
        tiles[y][x] = newTile;
        return this.copyWith({ tiles });
    }
    /**
     * Replace or modify all tiles in the grid.
     *
     * @param tiles The new tile array or a function to mutate the existing tile array.
     * @returns The new grid with the new tiles.
     */
    withTiles(tiles) {
        return this.copyWith({
            tiles: typeof tiles === 'function'
                ? tiles(this.tiles.map(row => row.slice()))
                : tiles,
        });
    }
    /**
     * Add or modify the connections in the grid.
     * @param connections The new connections to add or modify.
     * @returns The new grid with the new connections.
     */
    withConnections(connections) {
        return this.copyWith({
            connections: typeof connections === 'function'
                ? connections(this.connections)
                : connections,
        });
    }
    /**
     * Add or modify the symbols in the grid.
     * @param symbols The new symbols to add or modify.
     * @returns The new grid with the new symbols.
     */
    withSymbols(symbols) {
        if (symbols instanceof Array) {
            const map = new Map();
            for (const symbol of symbols) {
                if (map.has(symbol.id)) {
                    map.set(symbol.id, [...map.get(symbol.id), symbol]);
                }
                else {
                    map.set(symbol.id, [symbol]);
                }
            }
            return this.copyWith({ symbols: map });
        }
        return this.copyWith({
            symbols: typeof symbols === 'function'
                ? symbols(new Map(this.symbols))
                : symbols,
        });
    }
    /**
     * Add a new symbol to the grid.
     * @param symbol The symbol to add.
     * @returns The new grid with the new symbol.
     */
    addSymbol(symbol) {
        return this.withSymbols(map => {
            if (map.has(symbol.id)) {
                return map.set(symbol.id, [...map.get(symbol.id), symbol]);
            }
            else {
                return map.set(symbol.id, [symbol]);
            }
        });
    }
    /**
     * Remove an instance of the symbol from the grid.
     * @param symbol The symbol to remove.
     * @returns The new grid with the symbol removed.
     */
    removeSymbol(symbol) {
        return this.withSymbols(map => {
            if (map.has(symbol.id)) {
                const symbols = map.get(symbol.id).filter(s => s !== symbol);
                if (symbols.length === 0) {
                    map.delete(symbol.id);
                }
                else {
                    map.set(symbol.id, symbols);
                }
            }
            return map;
        });
    }
    /**
     * Remove all symbols that satisfy the predicate.
     * @param predicate The predicate to test each symbol with.
     * @returns The new grid with the symbols removed.
     */
    removeSymbolIf(predicate) {
        return this.withSymbols(map => {
            for (const [id, symbols] of map) {
                const newSymbols = symbols.filter(sym => !predicate(sym));
                if (newSymbols.length === 0) {
                    map.delete(id);
                }
                else {
                    map.set(id, newSymbols);
                }
            }
            return map;
        });
    }
    /**
     * Find the first symbol that satisfies the predicate.
     * @param predicate The predicate to test each symbol with.
     * @returns The first symbol that satisfies the predicate, or undefined if no symbol is found.
     */
    findSymbol(predicate) {
        for (const symbols of this.symbols.values()) {
            const symbol = symbols.find(predicate);
            if (symbol)
                return symbol;
        }
    }
    /**
     * Replace an existing symbol with a new symbol.
     * @param oldSymbol The symbol to replace.
     * @param newSymbol The new symbol to replace with.
     * @returns The new grid with the symbol replaced.
     */
    replaceSymbol(oldSymbol, newSymbol) {
        return this.withSymbols(map => {
            if (map.has(oldSymbol.id)) {
                const symbols = map
                    .get(oldSymbol.id)
                    .map(s => (s === oldSymbol ? newSymbol : s));
                map.set(oldSymbol.id, symbols);
            }
            return map;
        });
    }
    /**
     * Add or modify the rules in the grid.
     * @param rules The new rules to add or modify.
     * @returns The new grid with the new rules.
     */
    withRules(rules) {
        return this.copyWith({
            rules: typeof rules === 'function' ? rules(this.rules) : rules,
        });
    }
    /**
     * Add a new rule to the grid.
     * @param rule The rule to add.
     * @returns The new grid with the new rule.
     */
    addRule(rule) {
        return this.withRules(rules => [...rules, rule]);
    }
    /**
     * Remove an instance of the rule from the grid.
     * @param rule The rule to remove.
     * @returns The new grid with the rule removed.
     */
    removeRule(rule) {
        return this.withRules(rules => rules.filter(r => r !== rule));
    }
    /**
     * Remove all rules that satisfy the predicate.
     * @param predicate The predicate to test each rule with.
     * @returns The new grid with the rules removed.
     */
    removeRuleIf(predicate) {
        return this.withRules(rules => rules.filter(r => !predicate(r)));
    }
    /**
     * Find the first rule that satisfies the predicate.
     * @param predicate The predicate to test each rule with.
     * @returns The first rule that satisfies the predicate, or undefined if no rule is found.
     */
    findRule(predicate) {
        return this.rules.find(predicate);
    }
    /**
     * Replace an existing rule with a new rule.
     * @param oldRule The rule to replace.
     * @param newRule The new rule to replace with.
     * @returns The new grid with the rule replaced.
     */
    replaceRule(oldRule, newRule) {
        return this.withRules(rules => rules.map(r => (r === oldRule ? newRule : r)));
    }
    /**
     * Insert a new column at the given index, shifting all components of the grid accordingly. Newly inserted tiles are gray.
     * @param index The index to insert the column at.
     * @returns The new grid with the new column inserted.
     */
    insertColumn(index) {
        if (index < 0 || index > this.width)
            return this;
        const tiles = array(this.width + 1, this.height, (x, y) => {
            if (x < index)
                return this.getTile(x, y);
            if (x === index)
                return TileData.empty();
            return this.getTile(x - 1, y);
        });
        const connections = this.connections.insertColumn(index);
        const rules = this.rules
            .map(rule => {
            if (handlesGridResize(rule))
                return rule.onGridResize(this, 'insert', 'column', index);
            else
                return rule;
        })
            .filter(rule => rule !== null);
        const symbols = new Map();
        for (const [id, symbolList] of this.symbols) {
            const newList = symbolList
                .map(symbol => symbol.onGridResize(this, 'insert', 'column', index))
                .filter(symbol => symbol !== null);
            if (newList.length > 0)
                symbols.set(id, newList);
        }
        return this.copyWith({
            width: this.width + 1,
            tiles,
            connections,
            rules,
            symbols,
        });
    }
    /**
     * Insert a new row at the given index, shifting all components of the grid accordingly. Newly inserted tiles are gray.
     * @param index The index to insert the row at.
     * @returns The new grid with the new row inserted.
     */
    insertRow(index) {
        if (index < 0 || index > this.height)
            return this;
        const tiles = array(this.width, this.height + 1, (x, y) => {
            if (y < index)
                return this.getTile(x, y);
            if (y === index)
                return TileData.empty();
            return this.getTile(x, y - 1);
        });
        const connections = this.connections.insertRow(index);
        const rules = this.rules
            .map(rule => {
            if (handlesGridResize(rule))
                return rule.onGridResize(this, 'insert', 'row', index);
            else
                return rule;
        })
            .filter(rule => rule !== null);
        const symbols = new Map();
        for (const [id, symbolList] of this.symbols) {
            const newList = symbolList
                .map(symbol => symbol.onGridResize(this, 'insert', 'row', index))
                .filter(symbol => symbol !== null);
            if (newList.length > 0)
                symbols.set(id, newList);
        }
        return this.copyWith({
            height: this.height + 1,
            tiles,
            connections,
            rules,
            symbols,
        });
    }
    /**
     * Remove a column at the given index, shifting all components of the grid accordingly.
     * @param index The index to remove the column at.
     * @returns The new grid with the column removed.
     */
    removeColumn(index) {
        if (index < 0 || index >= this.width)
            return this;
        const tiles = array(this.width - 1, this.height, (x, y) => x < index ? this.getTile(x, y) : this.getTile(x + 1, y));
        const connections = this.connections.removeColumn(index);
        const rules = this.rules
            .map(rule => {
            if (handlesGridResize(rule))
                return rule.onGridResize(this, 'remove', 'column', index);
            else
                return rule;
        })
            .filter(rule => rule !== null);
        const symbols = new Map();
        for (const [id, symbolList] of this.symbols) {
            const newList = symbolList
                .map(symbol => symbol.onGridResize(this, 'remove', 'column', index))
                .filter(symbol => symbol !== null);
            if (newList.length > 0)
                symbols.set(id, newList);
        }
        return this.copyWith({
            width: this.width - 1,
            tiles,
            connections,
            rules,
            symbols,
        });
    }
    /**
     * Remove a row at the given index, shifting all components of the grid accordingly.
     * @param index The index to remove the row at.
     * @returns The new grid with the row removed.
     */
    removeRow(index) {
        if (index < 0 || index >= this.height)
            return this;
        const tiles = array(this.width, this.height - 1, (x, y) => y < index ? this.getTile(x, y) : this.getTile(x, y + 1));
        const connections = this.connections.removeRow(index);
        const rules = this.rules
            .map(rule => {
            if (handlesGridResize(rule))
                return rule.onGridResize(this, 'remove', 'row', index);
            else
                return rule;
        })
            .filter(rule => rule !== null);
        const symbols = new Map();
        for (const [id, symbolList] of this.symbols) {
            const newList = symbolList
                .map(symbol => symbol.onGridResize(this, 'remove', 'row', index))
                .filter(symbol => symbol !== null);
            if (newList.length > 0)
                symbols.set(id, newList);
        }
        return this.copyWith({
            height: this.height - 1,
            tiles,
            connections,
            rules,
            symbols,
        });
    }
    /**
     * Resize the grid to the new width and height, shifting all components of the grid accordingly. Newly inserted tiles are gray.
     * @param width The new width of the grid.
     * @param height The new height of the grid.
     * @returns The new grid with the new dimensions.
     */
    resize(width, height) {
        if (width < 0 || height < 0)
            throw new Error(`Invalid grid size: ${width}x${height}`);
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        let newGrid = this;
        while (newGrid.width < width)
            newGrid = newGrid.insertColumn(newGrid.width);
        while (newGrid.width > width)
            newGrid = newGrid.removeColumn(newGrid.width - 1);
        while (newGrid.height < height)
            newGrid = newGrid.insertRow(newGrid.height);
        while (newGrid.height > height)
            newGrid = newGrid.removeRow(newGrid.height - 1);
        return newGrid;
    }
    /**
     * Create a new mutable TileData array from a string array.
     *
     * - Use `b` for dark cells, `w` for light cells, and `n` for gray cells.
     * - Capitalize the letter to make the tile fixed.
     * - Use `.` to represent empty space.
     *
     * @param array - The string array to create the tiles from.
     * @returns The created tile array.
     */
    static createTiles(array) {
        const width = array.reduce((max, row) => Math.max(max, row.length), 0);
        return array.map(row => Array.from({ length: width }, (_, x) => {
            return TileData.create(row.charAt(x));
        }));
    }
    /**
     * Create a new GridData object from a string array.
     *
     * - Use `b` for dark cells, `w` for light cells, and `n` for gray cells.
     * - Capitalize the letter to make the tile fixed.
     * - Use `.` to represent empty space.
     *
     * @param array - The string array to create the grid from.
     * @returns The created grid.
     */
    static create(array) {
        const tiles = GridData.createTiles(array);
        return new GridData(tiles[0]?.length ?? 0, tiles.length, tiles);
    }
    /**
     * Find a tile in the grid that satisfies the predicate.
     *
     * @param predicate The predicate to test each tile with.
     * @returns The position of the first tile that satisfies the predicate, or undefined if no tile is found.
     */
    find(predicate) {
        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                if (predicate(this.getTile(x, y), x, y)) {
                    return { x, y };
                }
            }
        }
        return undefined;
    }
    /**
     * Iterate over all tiles in the same region as the given position that satisfy the predicate.
     * The iteration stops when the callback returns a value that is not undefined.
     * Non-existent tiles are not included in the iteration.
     *
     * @param position The position to start the iteration from. This position is included in the iteration.
     * @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
     * @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
     * @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
     */
    iterateArea(position, predicate, callback) {
        const tile = this.getTile(position.x, position.y);
        if (!tile.exists || !predicate(tile)) {
            return;
        }
        const visited = array(this.width, this.height, () => false);
        const stack = [position];
        while (stack.length > 0) {
            const { x, y } = stack.pop();
            if (visited[y][x]) {
                continue;
            }
            visited[y][x] = true;
            const ret = callback(this.getTile(x, y), x, y);
            if (ret !== undefined)
                return ret;
            for (const offset of NEIGHBOR_OFFSETS) {
                const next = { x: x + offset.x, y: y + offset.y };
                if (next.x >= 0 &&
                    next.x < this.width &&
                    next.y >= 0 &&
                    next.y < this.height) {
                    const nextTile = this.getTile(next.x, next.y);
                    if (nextTile.exists && predicate(nextTile))
                        stack.push(next);
                }
            }
        }
    }
    /**
     * Iterate over all tiles in a straight line from the given position in the given direction that satisfy the predicate.
     * The iteration stops when the callback returns a value that is not undefined.
     * Non-existent tiles break the iteration.
     *
     * @param position The position to start the iteration from. This position is included in the iteration.
     * @param direction The direction to iterate in.
     * @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
     * @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
     * @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
     */
    iterateDirection(position, direction, predicate, callback) {
        return this.iterateDirectionAll(position, direction, tile => tile.exists && predicate(tile), callback);
    }
    /**
     * Iterate over all tiles in a straight line from the given position in the given direction that satisfy the predicate.
     * The iteration stops when the callback returns a value that is not undefined.
     * Non-existent tiles are included in the iteration.
     *
     * @param position The position to start the iteration from. This position is included in the iteration.
     * @param direction The direction to iterate in.
     * @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
     * @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
     * @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
     */
    iterateDirectionAll(position, direction, predicate, callback) {
        let current = position;
        while (this.isPositionValid(current.x, current.y)) {
            const tile = this.getTile(current.x, current.y);
            if (!predicate(tile)) {
                break;
            }
            const ret = callback(tile, current.x, current.y);
            if (ret !== undefined)
                return ret;
            current = move(current, direction);
        }
    }
    /**
     * Check if every tile in the grid is filled with a color other than gray.
     *
     * @returns True if every tile is filled with a color other than gray, false otherwise.
     */
    isComplete() {
        return this.tiles.every(row => row.every(tile => !tile.exists || tile.color !== Color.Gray));
    }
    /**
     * Iterate over all tiles in the grid.
     * The iteration stops when the callback returns a value that is not undefined.
     *
     * @param callback The callback to call for each tile.
     * @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
     */
    forEach(callback) {
        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                const ret = callback(this.getTile(x, y), x, y);
                if (ret !== undefined)
                    return ret;
            }
        }
    }
    /**
     * Flood fill a continuous region starting from the given position with the given color.
     *
     * @param position The position to start the flood fill from.
     * @param from The color of the tiles to fill.
     * @param to The color to fill the tiles with.
     * @param allowFixed Whether to fill fixed tiles.
     * @returns The new grid with the region filled with the new color.
     */
    floodFill(position, from, to, allowFixed) {
        const tiles = array(this.width, this.height, (x, y) => this.getTile(x, y));
        this.iterateArea(position, t => t.color === from && (allowFixed || !t.fixed), (tile, x, y) => {
            tiles[y][x] = tile.withColor(to);
        });
        return this.copyWith({ tiles });
    }
    /**
     * Flood fill all tiles with the given color to a new color, even if they are not connected.
     *
     * @param from The color of the tiles to fill.
     * @param to The color to fill the tiles with.
     * @param allowFixed Whether to fill fixed tiles.
     * @returns The new grid with all tiles filled with the new color.
     */
    floodFillAll(from, to, allowFixed) {
        return this.copyWith({
            tiles: this.tiles.map(row => row.map(tile => tile.color === from && (allowFixed || !tile.fixed)
                ? tile.withColor(to)
                : tile)),
        });
    }
    /**
     * Check if the grid has any instructions that require a custom solution.
     * @returns True if the grid has any instructions that require a custom solution, false otherwise.
     */
    requireSolution() {
        if (this.rules.some(rule => rule.validateWithSolution))
            return true;
        if ([...this.symbols.values()].some(list => list.some(symbol => symbol.validateWithSolution)))
            return true;
        return false;
    }
    /**
     * Reset all non-fixed tiles to gray.
     *
     * @returns The new grid with all non-fixed tiles reset to gray.
     */
    resetTiles() {
        let changed = false;
        const newTiles = array(this.width, this.height, (x, y) => {
            const tile = this.getTile(x, y);
            if (tile.exists && !tile.fixed && tile.color !== Color.Gray) {
                changed = true;
                return tile.withColor(Color.Gray);
            }
            return tile;
        });
        if (!changed)
            return this;
        let newGrid = this.copyWith({ tiles: newTiles });
        this.symbols.forEach(list => {
            list.forEach(symbol => {
                if (handlesSetGrid(symbol)) {
                    newGrid = symbol.onSetGrid(this, newGrid);
                }
            });
        });
        this.rules.forEach(rule => {
            if (handlesSetGrid(rule)) {
                newGrid = rule.onSetGrid(this, newGrid);
            }
        });
        return newGrid;
    }
    /**
     * Copy the tiles in the given region to a new grid.
     * All connections and symbols within the selected region are copied.
     * All rules are included as well.
     *
     * @param origin The top-left corner of the region to copy.
     * @param width The width of the region to copy.
     * @param height The height of the region to copy.
     * @returns The new grid with the copied tiles.
     */
    copyTiles(origin, width, height) {
        const newTiles = array(width, height, (x, y) => this.getTile(origin.x + x, origin.y + y));
        const connections = new GridConnections(this.connections.edges
            .filter(edge => edge.x1 >= origin.x &&
            edge.y1 >= origin.y &&
            edge.x2 >= origin.x &&
            edge.y2 >= origin.y &&
            edge.x1 < origin.x + width &&
            edge.y1 < origin.y + height &&
            edge.x2 < origin.x + width &&
            edge.y2 < origin.y + height)
            .map(edge => ({
            x1: edge.x1 - origin.x,
            y1: edge.y1 - origin.y,
            x2: edge.x2 - origin.x,
            y2: edge.y2 - origin.y,
        })));
        const symbols = new Map();
        for (const [id, symbolList] of this.symbols) {
            const newSymbolList = symbolList.filter(symbol => symbol.x >= origin.x &&
                symbol.y >= origin.y &&
                symbol.x < origin.x + width &&
                symbol.y < origin.y + height);
            if (newSymbolList.length > 0)
                symbols.set(id, newSymbolList);
        }
        return new GridData(width, height, newTiles, connections, symbols, this.rules);
    }
    pasteTiles(origin, grid) {
        if (!(grid instanceof GridData))
            return this.pasteTiles(origin, new GridData(grid[0].length, grid.length, grid));
        const newTiles = this.tiles.map(row => [...row]);
        grid.forEach((tile, x, y) => {
            if (this.isPositionValid(origin.x + x, origin.y + y))
                newTiles[origin.y + y][origin.x + x] = tile;
        });
        const connections = new GridConnections([
            ...this.connections.edges,
            ...grid.connections.edges.map(edge => ({
                x1: edge.x1 + origin.x,
                y1: edge.y1 + origin.y,
                x2: edge.x2 + origin.x,
                y2: edge.y2 + origin.y,
            })),
        ]);
        const symbols = new Map(this.symbols);
        for (const [id, sourceList] of grid.symbols) {
            const symbolList = sourceList.map(symbol => symbol.copyWith({ x: symbol.x + origin.x, y: symbol.y + origin.y }));
            if (symbols.has(id)) {
                symbols.set(id, [...symbols.get(id), ...symbolList]);
            }
            else {
                symbols.set(id, symbolList);
            }
        }
        const rules = [...this.rules, ...grid.rules];
        return this.copyWith({ tiles: newTiles, connections, symbols, rules });
    }
    /**
     * Check if this grid is equal to another grid in terms of size and tile colors.
     * Rules, symbols, and connections are not compared.
     *
     * @param grid The grid to compare with.
     * @returns True if the grids are equal in size and tile colors, false otherwise.
     */
    colorEquals(grid) {
        return (this.width === grid.width &&
            this.height === grid.height &&
            this.tiles.every((row, y) => row.every((tile, x) => (!tile.exists && !grid.getTile(x, y).exists) ||
                tile.color === grid.getTile(x, y).color)));
    }
    /**
     * Check if this grid is equal to another grid in terms of size, tile colors, connections, symbols, and rules.
     *
     * @param other The grid to compare with.
     * @returns True if the grids are equal, false otherwise.
     */
    equals(other) {
        if (this.width !== other.width)
            return false;
        if (this.height !== other.height)
            return false;
        if (this.tiles.some((row, y) => row.some((tile, x) => !tile.equals(other.getTile(x, y)))))
            return false;
        if (!this.connections.equals(other.connections))
            return false;
        if (this.symbols.size !== other.symbols.size)
            return false;
        for (const [id, symbols] of this.symbols) {
            const otherSymbols = other.symbols.get(id);
            if (!otherSymbols || symbols.length !== otherSymbols.length)
                return false;
            for (const symbol of symbols) {
                if (!otherSymbols.some(s => symbol.equals(s)))
                    return false;
            }
        }
        if (this.rules.length !== other.rules.length)
            return false;
        for (const rule of this.rules) {
            if (!other.rules.some(r => rule.equals(r)))
                return false;
        }
        return true;
    }
    /**
     * Get the count of tiles that satisfy the given conditions.
     * @param exists Whether the tile exists or not.
     * @param fixed Whether the tile is fixed or not. If undefined, the fixed state is ignored.
     * @param color The color of the tile. If undefined, all colors are included.
     * @returns The count of tiles that satisfy the given conditions.
     */
    getTileCount(exists, fixed, color) {
        let count = 0;
        this.forEach(tile => {
            if (tile.exists !== exists)
                return;
            if (fixed !== undefined && tile.fixed !== fixed)
                return;
            if (color !== undefined && tile.color !== color)
                return;
            count++;
        });
        return count;
    }
    /**
     * Get the count of tiles that satisfy the given conditions for each color.
     * @param color The color of the tiles.
     * @returns The count of tiles that satisfy the given conditions for each color.
     */
    getColorCount(color) {
        let min = 0;
        let max = this.width * this.height;
        this.forEach(tile => {
            if (!tile.exists || (tile.fixed && tile.color !== color)) {
                max--;
            }
            if (tile.exists && tile.fixed && tile.color === color) {
                min++;
            }
        });
        return { min, max };
    }
    /**
     * Deduplicate the rules in the given list.
     *
     * @param rules The list of rules to deduplicate.
     * @returns The deduplicated list of rules.
     */
    static deduplicateRules(rules) {
        return rules.filter((rule, index, self) => self.findIndex(r => r.equals(rule)) === index);
    }
    /**
     * Deduplicate the singleton rules in the given list.
     *
     * @param rules The list of rules to deduplicate.
     * @returns The deduplicated list of rules.
     */
    static deduplicateSingletonRules(rules) {
        return rules.filter((rule, index, self) => !rule.isSingleton || self.findIndex(r => r.id === rule.id) === index);
    }
    /**
     * Deduplicate the symbols in the given map.
     *
     * @param symbols The map of symbols to deduplicate.
     * @returns The deduplicated map of symbols.
     */
    static deduplicateSymbols(symbols) {
        const map = new Map();
        for (const [id, symbolList] of symbols) {
            map.set(id, symbolList.filter((symbol, index, self) => self.findIndex(s => symbol.equals(s)) === index));
        }
        return map;
    }
}
