// Asatsuyu - Solvable Nonogram Studio
// Copyright (c) 2026 Asatsuyu
// Licensed under the MIT License.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED.
//
// A nonogram (picture-logic-puzzle) author's tool. Unlike a plain clue
// generator, every design is run through a line-solver (forward/backward
// reachability over each row and column, iterated to a fixpoint) that
// determines exactly which cells a human could pin down by pure logic
// alone. Cells the solver cannot resolve are flagged as ambiguous, and a
// bounded search over single- then double-pixel edits suggests the
// smallest change that removes the ambiguity.
import org.teavm.jso.JSExport;
import org.teavm.jso.browser.AnimationFrameCallback;
import org.teavm.jso.browser.Window;
import org.teavm.jso.dom.events.Event;
import org.teavm.jso.dom.events.EventListener;
import org.teavm.jso.dom.events.KeyboardEvent;
import org.teavm.jso.dom.html.HTMLDocument;
import org.teavm.jso.dom.html.HTMLElement;
import org.teavm.jso.dom.html.HTMLInputElement;
import org.teavm.jso.dom.xml.Node;

public class Main {
    static final int[] SIZE_OPTIONS = {5, 8, 10, 12, 15};

    static final int PLAY_EMPTY = 0;
    static final int PLAY_FILLED = 1;
    static final int PLAY_CROSS = 2;

    static final int TRI_UNKNOWN = -1;
    static final int TRI_EMPTY = 0;
    static final int TRI_FILLED = 1;

    static final int DOUBLE_FIX_CANDIDATE_CAP = 40;

    // "Suggest fix" is a bounded but potentially large search (single-flip
    // pass over every cell, then a pair pass over up to
    // DOUBLE_FIX_CANDIDATE_CAP candidates); running it in one blocking call
    // would freeze the page on large grids. It is instead driven in small
    // time-sliced chunks via Window.setTimeout — these constants cap how
    // many solveGrid() calls happen per slice.
    static final int SEARCH_SINGLE_SLICE = 40;
    static final int SEARCH_DOUBLE_SLICE = 25;

    // Incremental "Suggest fix" search state. searchGeneration is bumped
    // every time a new search starts; a slice whose generation no longer
    // matches (superseded by a fresh search) or whose master has gone null
    // (edited/cleared mid-search) silently stops instead of continuing.
    static int searchGeneration = 0;
    static int searchPhase; // 1 = single-flip scan, 2 = pair scan
    static int searchSingleIdx;
    static int[] searchCandidates;
    static int searchCandidateCount;
    static int searchPairI;
    static int searchPairJ;

    static HTMLDocument doc;

    static int width = 10;
    static int height = 10;
    static boolean[] design;
    static int[] playState;
    static boolean playMode = false;
    static boolean playModeSolvable = false; // was the design uniquely solvable when play mode was entered
    static boolean playCompletionAnnounced = false; // status currently reads "Solved!"/"Solution revealed."
    static int[] master; // last solveGrid() result, or null if stale
    static int[] suggestion; // flat {r,c} or {r1,c1,r2,c2}; length 0 = searched, none found
    static int focusedIdx = 0; // roving tabindex: only this cell has tabindex=0
    static String lastStatus = null; // guards setStatus against redundant aria-live re-announcements

    static HTMLElement gridEl;
    static HTMLElement rowCluesEl;
    static HTMLElement colCluesEl;
    static HTMLElement statusEl;
    static HTMLElement drawActionsEl;
    static HTMLElement playActionsEl;
    static HTMLElement verifyBtn;
    static HTMLElement suggestBtn;
    static HTMLElement applyBtn;
    static HTMLElement modeDrawBtn;
    static HTMLElement modePlayBtn;
    static HTMLElement[] sizeButtons = new HTMLElement[SIZE_OPTIONS.length];
    static HTMLElement[] cellEls;
    static HTMLElement[] rowClueEls;
    static HTMLElement[] colClueEls;
    static HTMLInputElement shareCodeInput;
    static HTMLInputElement loadCodeInput;

    public static void main(String[] args) {
        doc = HTMLDocument.current();
        gridEl = byId("ns-grid");
        rowCluesEl = byId("ns-rowclues");
        colCluesEl = byId("ns-colclues");
        statusEl = byId("ns-status");
        drawActionsEl = byId("ns-draw-actions");
        playActionsEl = byId("ns-play-actions");

        setupActionButtons();
        setupModeButtons();
        setupSizeButtons();
        setupShareControls();

        rebuildBoard();
    }

    // ==== DOM helpers ====

    static HTMLElement byId(String id) {
        return (HTMLElement) doc.getElementById(id);
    }

    static HTMLElement create(String tag) {
        return (HTMLElement) doc.createElement(tag);
    }

    static void clearChildren(HTMLElement el) {
        Node child;
        while ((child = el.getFirstChild()) != null) {
            el.removeChild(child);
        }
    }

    static void setText(HTMLElement el, String text) {
        clearChildren(el);
        el.appendChild(doc.createTextNode(text));
    }

    static void setStatus(String message) {
        if (message.equals(lastStatus)) {
            return;
        }
        lastStatus = message;
        setText(statusEl, message);
    }

    static void setDisabled(HTMLElement el, boolean disabled) {
        if (disabled) {
            el.setAttribute("disabled", "true");
        } else {
            el.removeAttribute("disabled");
        }
    }

    static void setHidden(HTMLElement el, boolean hidden) {
        if (hidden) {
            el.setAttribute("hidden", "hidden");
        } else {
            el.removeAttribute("hidden");
        }
    }

    static void setFocusedIdx(int idx) {
        if (focusedIdx != idx && focusedIdx >= 0 && focusedIdx < cellEls.length) {
            cellEls[focusedIdx].setAttribute("tabindex", "-1");
        }
        focusedIdx = idx;
        cellEls[idx].setAttribute("tabindex", "0");
    }

    // ==== setup ====

    static void setupActionButtons() {
        verifyBtn = byId("ns-verify");
        suggestBtn = byId("ns-suggest");
        applyBtn = byId("ns-apply");
        HTMLElement clearBtn = byId("ns-clear");
        HTMLElement checkBtn = byId("ns-check");
        HTMLElement revealBtn = byId("ns-reveal");
        HTMLElement backBtn = byId("ns-back");

        EventListener<Event> verifyHandler = evt -> onVerify();
        verifyBtn.addEventListener("click", verifyHandler);

        EventListener<Event> suggestHandler = evt -> onSuggest();
        suggestBtn.addEventListener("click", suggestHandler);

        EventListener<Event> applyHandler = evt -> onApplySuggestion();
        applyBtn.addEventListener("click", applyHandler);

        EventListener<Event> clearHandler = evt -> onClear();
        clearBtn.addEventListener("click", clearHandler);

        EventListener<Event> checkHandler = evt -> onCheck();
        checkBtn.addEventListener("click", checkHandler);

        EventListener<Event> revealHandler = evt -> onReveal();
        revealBtn.addEventListener("click", revealHandler);

        EventListener<Event> backHandler = evt -> {
            exitPlayMode();
            modeDrawBtn.focus();
            updateModeButtonStates();
        };
        backBtn.addEventListener("click", backHandler);
    }

    static void setupModeButtons() {
        modeDrawBtn = byId("ns-mode-draw");
        modePlayBtn = byId("ns-mode-play");

        EventListener<Event> drawHandler = evt -> {
            if (playMode) {
                exitPlayMode();
                updateModeButtonStates();
            }
        };
        modeDrawBtn.addEventListener("click", drawHandler);

        EventListener<Event> playHandler = evt -> {
            if (!playMode) {
                enterPlayMode();
                updateModeButtonStates();
            }
        };
        modePlayBtn.addEventListener("click", playHandler);
    }

    static void updateModeButtonStates() {
        modeDrawBtn.setAttribute("aria-pressed", playMode ? "false" : "true");
        modePlayBtn.setAttribute("aria-pressed", playMode ? "true" : "false");
        setHidden(drawActionsEl, playMode);
        setHidden(playActionsEl, !playMode);
        setSizeButtonsEnabled(!playMode);
    }

    static void setupSizeButtons() {
        for (int i = 0; i < SIZE_OPTIONS.length; i++) {
            int size = SIZE_OPTIONS[i];
            HTMLElement btn = byId("ns-size-" + size);
            sizeButtons[i] = btn;
            EventListener<Event> handler = evt -> {
                if (size == width && size == height) {
                    return;
                }
                width = size;
                height = size;
                rebuildBoard();
            };
            btn.addEventListener("click", handler);
        }
        updateSizeButtonStates();
    }

    static void setSizeButtonsEnabled(boolean enabled) {
        for (HTMLElement btn : sizeButtons) {
            setDisabled(btn, !enabled);
        }
    }

    static void updateSizeButtonStates() {
        for (int i = 0; i < SIZE_OPTIONS.length; i++) {
            boolean active = SIZE_OPTIONS[i] == width && width == height;
            sizeButtons[i].setAttribute("aria-pressed", active ? "true" : "false");
        }
    }

    static boolean isSizeOption(int size) {
        for (int i = 0; i < SIZE_OPTIONS.length; i++) {
            if (SIZE_OPTIONS[i] == size) {
                return true;
            }
        }
        return false;
    }

    static void setupShareControls() {
        shareCodeInput = (HTMLInputElement) doc.getElementById("ns-share-code");
        loadCodeInput = (HTMLInputElement) doc.getElementById("ns-load-code");
        HTMLElement loadBtn = byId("ns-load");
        EventListener<Event> loadHandler = evt -> onLoadCode();
        loadBtn.addEventListener("click", loadHandler);
    }

    // ==== board lifecycle ====

    static void rebuildBoard() {
        int n = width * height;
        design = new boolean[n];
        playState = new int[n];
        master = null;
        suggestion = null;
        playMode = false;
        focusedIdx = 0;

        clearChildren(gridEl);
        clearChildren(rowCluesEl);
        clearChildren(colCluesEl);

        gridEl.setAttribute(
                "style",
                "grid-template-columns: repeat(" + width + ", var(--space-8));"
                        + " grid-template-rows: repeat(" + height + ", var(--space-8));");
        colCluesEl.setAttribute(
                "style", "grid-template-columns: repeat(" + width + ", var(--space-8));");
        rowCluesEl.setAttribute(
                "style", "grid-template-rows: repeat(" + height + ", var(--space-8));");

        cellEls = new HTMLElement[n];
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                int idx = r * width + c;
                HTMLElement cell = create("button");
                cell.setAttribute("type", "button");
                cell.setAttribute("class", "ns-cell");
                cell.setAttribute("tabindex", idx == 0 ? "0" : "-1");
                cell.setAttribute("aria-describedby", "ns-rowclue-" + r + " ns-colclue-" + c);

                EventListener<Event> clickHandler = evt -> {
                    if (playMode) {
                        cyclePlayCell(idx);
                    } else {
                        toggleDesignCell(idx);
                    }
                };
                cell.addEventListener("click", clickHandler);

                EventListener<Event> focusHandler = evt -> setFocusedIdx(idx);
                cell.addEventListener("focus", focusHandler);

                EventListener<Event> keyHandler = evt -> {
                    KeyboardEvent ke = (KeyboardEvent) evt;
                    String key = ke.getKey();
                    int nr = idx / width;
                    int nc = idx % width;
                    int targetIdx = -1;
                    if ("ArrowUp".equals(key) && nr > 0) {
                        targetIdx = (nr - 1) * width + nc;
                    } else if ("ArrowDown".equals(key) && nr < height - 1) {
                        targetIdx = (nr + 1) * width + nc;
                    } else if ("ArrowLeft".equals(key) && nc > 0) {
                        targetIdx = nr * width + (nc - 1);
                    } else if ("ArrowRight".equals(key) && nc < width - 1) {
                        targetIdx = nr * width + (nc + 1);
                    }
                    if (targetIdx >= 0) {
                        ke.preventDefault();
                        cellEls[targetIdx].focus();
                    }
                };
                cell.addEventListener("keydown", keyHandler);

                gridEl.appendChild(cell);
                cellEls[idx] = cell;
            }
        }

        rowClueEls = new HTMLElement[height];
        for (int r = 0; r < height; r++) {
            HTMLElement el = create("div");
            el.setAttribute("class", "ns-rowclue");
            el.setAttribute("id", "ns-rowclue-" + r);
            rowCluesEl.appendChild(el);
            rowClueEls[r] = el;
        }

        colClueEls = new HTMLElement[width];
        for (int c = 0; c < width; c++) {
            HTMLElement el = create("div");
            el.setAttribute("class", "ns-colclue");
            el.setAttribute("id", "ns-colclue-" + c);
            colCluesEl.appendChild(el);
            colClueEls[c] = el;
        }

        setDisabled(suggestBtn, true);
        updateApplyButtonVisibility();
        updateSizeButtonStates();
        updateModeButtonStates();
        renderClues();
        renderCells();
        updateShareCode();
        setStatus("New " + width + "×" + height + " grid ready. Draw your picture, then Verify.");
    }

    static void updateApplyButtonVisibility() {
        boolean show = suggestion != null && suggestion.length > 0;
        setHidden(applyBtn, !show);
    }

    // Re-derives the draw-mode "Suggest fix"/"Apply" control state from
    // master/suggestion instead of assuming callers left it consistent —
    // shared by every path that can change master, suggestion, or leave
    // play mode, so none of them can strand the controls out of sync.
    static void refreshDrawControls() {
        setDisabled(suggestBtn, master == null || countUnknown(master) == 0);
        updateApplyButtonVisibility();
    }

    // ==== rendering ====

    static void renderClues() {
        int[][] rowClues = computeAllRowClues(design, width, height);
        int[][] colClues = computeAllColClues(design, width, height);

        for (int r = 0; r < height; r++) {
            HTMLElement el = rowClueEls[r];
            clearChildren(el);
            int[] clue = rowClues[r];
            for (int i = 0; i < clue.length; i++) {
                if (i > 0) {
                    el.appendChild(doc.createTextNode(" "));
                }
                HTMLElement num = create("span");
                num.setAttribute("class", "ns-cluenum");
                num.appendChild(doc.createTextNode(Integer.toString(clue[i])));
                el.appendChild(num);
            }
            el.setAttribute("aria-label", "Row " + (r + 1) + " clue: " + clueWords(clue));
        }

        for (int c = 0; c < width; c++) {
            HTMLElement el = colClueEls[c];
            clearChildren(el);
            int[] clue = colClues[c];
            for (int i = 0; i < clue.length; i++) {
                if (i > 0) {
                    el.appendChild(doc.createTextNode(" "));
                }
                HTMLElement num = create("span");
                num.setAttribute("class", "ns-cluenum");
                num.appendChild(doc.createTextNode(Integer.toString(clue[i])));
                el.appendChild(num);
            }
            el.setAttribute("aria-label", "Column " + (c + 1) + " clue: " + clueWords(clue));
        }
    }

    static String clueWords(int[] clue) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < clue.length; i++) {
            if (i > 0) {
                sb.append(' ');
            }
            sb.append(clue[i]);
        }
        return sb.toString();
    }

    static void renderCells() {
        int n = width * height;
        for (int idx = 0; idx < n; idx++) {
            HTMLElement cell = cellEls[idx];
            int r = idx / width;
            int c = idx % width;
            boolean filled;
            boolean ambiguous = false;
            boolean suggested = false;
            String glyph = "";
            String pressed;

            if (playMode) {
                int st = playState[idx];
                filled = st == PLAY_FILLED;
                if (st == PLAY_CROSS) {
                    glyph = "×";
                    pressed = "mixed";
                } else {
                    pressed = filled ? "true" : "false";
                }
            } else {
                filled = design[idx];
                if (master != null && master[idx] == TRI_UNKNOWN) {
                    ambiguous = true;
                    glyph = "?";
                }
                if (suggestion != null) {
                    for (int k = 0; k < suggestion.length; k += 2) {
                        if (suggestion[k] == r && suggestion[k + 1] == c) {
                            suggested = true;
                            break;
                        }
                    }
                }
                pressed = filled ? "true" : "false";
            }

            String cls = "ns-cell";
            if (ambiguous) {
                cls = cls + " ns-ambiguous";
            }
            if (suggested) {
                cls = cls + " ns-suggested";
            }
            cell.setAttribute("class", cls);
            cell.setAttribute("aria-pressed", pressed);
            String stateWord = (playMode && playState[idx] == PLAY_CROSS)
                    ? ", crossed"
                    : (filled ? ", filled" : ", empty");
            String label = "Row " + (r + 1) + ", column " + (c + 1)
                    + stateWord + (ambiguous ? ", needs guessing" : "")
                    + (suggested ? ", suggested fix" : "");
            cell.setAttribute("aria-label", label);

            clearChildren(cell);
            if (glyph.length() > 0) {
                cell.appendChild(doc.createTextNode(glyph));
            }
        }
    }

    // ==== draw-mode interaction ====

    static void toggleDesignCell(int idx) {
        design[idx] = !design[idx];
        master = null;
        suggestion = null;
        refreshDrawControls();
        renderClues();
        renderCells();
        updateShareCode();
        setStatus("Edited — verify to check solvability.");
    }

    static void onVerify() {
        if (!hasAnyFilled(design)) {
            master = null;
            suggestion = null;
            refreshDrawControls();
            renderCells();
            setStatus("Draw at least one filled cell first.");
            return;
        }
        master = solveGrid(design, width, height);
        int unknowns = countUnknown(master);
        if (unknowns == 0) {
            suggestion = null;
            setStatus("Uniquely solvable by pure logic.");
        } else {
            setStatus("Not solvable by line logic alone: " + unknowns
                    + " cell(s) need guessing."
                    + " Dashed cells mark those cells.");
        }
        refreshDrawControls();
        renderCells();
    }

    static void onSuggest() {
        if (master == null || countUnknown(master) == 0) {
            return;
        }
        setDisabled(suggestBtn, true);
        setHidden(applyBtn, true);
        setStatus("Searching for a fix…");
        searchGeneration++;
        int myGeneration = searchGeneration;
        searchPhase = 1;
        searchSingleIdx = 0;
        searchCandidates = null;
        searchCandidateCount = 0;
        searchPairI = 0;
        searchPairJ = 0;
        Window.requestAnimationFrame(
                (AnimationFrameCallback)
                        (double timestamp) -> Window.setTimeout(() -> runSearchSlice(myGeneration), 0));
    }

    // Runs one bounded slice of the "Suggest fix" search (a limited number of
    // solveGrid() calls) and — if the search is not finished, cancelled, or
    // superseded — schedules the next slice via Window.setTimeout so the
    // main thread yields between slices. This keeps the UI responsive (an
    // edit or a fresh search request is picked up between slices) instead of
    // blocking on the full search in one call.
    static void runSearchSlice(int generation) {
        if (generation != searchGeneration || playMode || master == null || countUnknown(master) == 0) {
            return;
        }

        if (searchPhase == 1) {
            int n = width * height;
            int processed = 0;
            while (searchSingleIdx < n && processed < SEARCH_SINGLE_SLICE) {
                int idx = searchSingleIdx;
                searchSingleIdx++;
                processed++;
                design[idx] = !design[idx];
                int[] result = solveGrid(design, width, height);
                design[idx] = !design[idx];
                if (countUnknown(result) == 0) {
                    suggestion = new int[]{idx / width, idx % width};
                    finishSearch("Suggested fix: flip 1 pixel at row " + (idx / width + 1)
                            + ", column " + (idx % width + 1) + ".");
                    return;
                }
            }
            if (searchSingleIdx >= n) {
                searchPhase = 2;
                buildDoubleFixCandidates();
                searchPairI = 0;
                searchPairJ = 1;
            }
            Window.setTimeout(() -> runSearchSlice(generation), 0);
            return;
        }

        int processed = 0;
        while (searchPairI < searchCandidateCount && processed < SEARCH_DOUBLE_SLICE) {
            if (searchPairJ >= searchCandidateCount) {
                searchPairI++;
                searchPairJ = searchPairI + 1;
                continue;
            }
            int a = searchCandidates[searchPairI];
            int b = searchCandidates[searchPairJ];
            searchPairJ++;
            processed++;
            design[a] = !design[a];
            design[b] = !design[b];
            int[] result = solveGrid(design, width, height);
            design[a] = !design[a];
            design[b] = !design[b];
            if (countUnknown(result) == 0) {
                suggestion = new int[]{a / width, a % width, b / width, b % width};
                finishSearch("Suggested fix: flip 2 pixels — row " + (a / width + 1) + ", column "
                        + (a % width + 1) + ", and row " + (b / width + 1) + ", column "
                        + (b % width + 1) + ".");
                return;
            }
        }
        if (searchPairI >= searchCandidateCount) {
            suggestion = new int[0];
            finishSearch("No small fix found — try editing the dashed cells directly.");
            return;
        }
        Window.setTimeout(() -> runSearchSlice(generation), 0);
    }

    // Builds the same "ambiguous cells first, then same-row/column cells,
    // capped at DOUBLE_FIX_CANDIDATE_CAP" candidate list that findDoubleFix
    // uses, but stores it in search state so the pair scan can be resumed
    // slice by slice instead of run to completion in one call.
    static void buildDoubleFixCandidates() {
        boolean[] ambigRow = new boolean[height];
        boolean[] ambigCol = new boolean[width];
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if (master[r * width + c] == TRI_UNKNOWN) {
                    ambigRow[r] = true;
                    ambigCol[c] = true;
                }
            }
        }

        int[] candidates = new int[width * height];
        int candidateCount = 0;
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if (master[r * width + c] == TRI_UNKNOWN) {
                    candidates[candidateCount] = r * width + c;
                    candidateCount++;
                }
            }
        }
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if ((ambigRow[r] || ambigCol[c]) && master[r * width + c] != TRI_UNKNOWN) {
                    candidates[candidateCount] = r * width + c;
                    candidateCount++;
                }
            }
        }
        if (candidateCount > DOUBLE_FIX_CANDIDATE_CAP) {
            candidateCount = DOUBLE_FIX_CANDIDATE_CAP;
        }
        searchCandidates = candidates;
        searchCandidateCount = candidateCount;
    }

    static void finishSearch(String message) {
        setStatus(message);
        updateApplyButtonVisibility();
        setDisabled(suggestBtn, false);
        // Only steal focus back if it's still sitting where the browser
        // dropped it when we disabled the button (or on the button
        // itself) — if the user has since tabbed to a real control,
        // leave their focus alone. Also skip entirely while in play
        // mode, where the button's container is hidden.
        if (!playMode) {
            Node active = doc.getActiveElement();
            if (active == doc.getBody() || active == suggestBtn) {
                suggestBtn.focus();
            }
        }
        renderCells();
    }

    static void onApplySuggestion() {
        if (suggestion == null || suggestion.length == 0) {
            return;
        }
        for (int k = 0; k < suggestion.length; k += 2) {
            int r = suggestion[k];
            int c = suggestion[k + 1];
            int idx = r * width + c;
            design[idx] = !design[idx];
        }
        suggestion = null;
        master = null;
        verifyBtn.focus();
        updateApplyButtonVisibility();
        renderClues();
        renderCells();
        updateShareCode();
        onVerify();
    }

    static void onClear() {
        int n = width * height;
        for (int i = 0; i < n; i++) {
            design[i] = false;
        }
        master = null;
        suggestion = null;
        refreshDrawControls();
        renderClues();
        renderCells();
        updateShareCode();
        setStatus("Cleared.");
    }

    // ==== play mode ====

    static void enterPlayMode() {
        if (!hasAnyFilled(design)) {
            setStatus("Draw at least one filled cell first.");
            return;
        }
        // Abort any in-flight "Suggest fix" search so a slice scheduled
        // before switching modes cannot land on and overwrite play-mode
        // state after the fact (see runSearchSlice's generation guard).
        searchGeneration++;
        playMode = true;
        playState = new int[width * height];
        playModeSolvable = master != null && countUnknown(master) == 0;
        playCompletionAnnounced = false;
        renderCells();
        setStatus(playPromptMessage());
    }

    static void exitPlayMode() {
        playMode = false;
        playCompletionAnnounced = false;
        refreshDrawControls();
        renderCells();
        setStatus("Back to drawing.");
    }

    static String playPromptMessage() {
        return playModeSolvable
                ? "Play mode — fill in the grid using the clues."
                : "Play mode — note: this design hasn't been verified as uniquely solvable yet.";
    }

    static void cyclePlayCell(int idx) {
        int st = playState[idx];
        st = (st + 1) % 3;
        playState[idx] = st;
        renderCells();
        checkPlayCompletion();
    }

    static void checkPlayCompletion() {
        int n = width * height;
        for (int i = 0; i < n; i++) {
            boolean shouldBeFilled = design[i];
            boolean isFilled = playState[i] == PLAY_FILLED;
            if (shouldBeFilled != isFilled) {
                if (playCompletionAnnounced) {
                    // The status previously claimed the puzzle was solved or
                    // revealed; that is no longer true, so replace it with
                    // the play-mode prompt instead of leaving it stale.
                    playCompletionAnnounced = false;
                    setStatus(playPromptMessage());
                }
                // Otherwise the prompt is already showing — avoid
                // re-broadcasting it on every single cell click.
                return;
            }
        }
        playCompletionAnnounced = true;
        setStatus("Solved! Every cell matches the picture.");
    }

    static void onCheck() {
        int n = width * height;
        int mismatches = 0;
        for (int i = 0; i < n; i++) {
            boolean shouldBeFilled = design[i];
            boolean isFilled = playState[i] == PLAY_FILLED;
            if (shouldBeFilled != isFilled) {
                mismatches++;
            }
        }
        if (mismatches == 0) {
            playCompletionAnnounced = true;
            setStatus("Solved! Every cell matches the picture.");
        } else {
            playCompletionAnnounced = false;
            setStatus(mismatches + " cell(s) do not match yet. Keep going.");
        }
    }

    static void onReveal() {
        int n = width * height;
        for (int i = 0; i < n; i++) {
            playState[i] = design[i] ? PLAY_FILLED : PLAY_EMPTY;
        }
        playCompletionAnnounced = true;
        renderCells();
        setStatus("Solution revealed.");
    }

    // ==== share codes ====

    static void updateShareCode() {
        StringBuilder sb = new StringBuilder();
        sb.append("NONO1:").append(width).append("x").append(height).append(":");
        int n = width * height;
        for (int i = 0; i < n; i++) {
            sb.append(design[i] ? '1' : '0');
        }
        shareCodeInput.setValue(sb.toString());
    }

    static void onLoadCode() {
        String code = loadCodeInput.getValue();
        if (code == null) {
            code = "";
        }
        code = code.trim();
        String prefix = "NONO1:";
        if (!code.startsWith(prefix)) {
            setStatus("Invalid share code.");
            return;
        }
        String rest = code.substring(prefix.length());
        int colonIdx = rest.indexOf(':');
        if (colonIdx < 0) {
            setStatus("Invalid share code.");
            return;
        }
        String dims = rest.substring(0, colonIdx);
        String bits = rest.substring(colonIdx + 1);
        int xIdx = dims.indexOf('x');
        if (xIdx < 0) {
            setStatus("Invalid share code.");
            return;
        }
        int w;
        int h;
        try {
            w = Integer.parseInt(dims.substring(0, xIdx));
            h = Integer.parseInt(dims.substring(xIdx + 1));
        } catch (NumberFormatException e) {
            setStatus("Invalid share code.");
            return;
        }
        if (!isSizeOption(w) || !isSizeOption(h)) {
            setStatus("Invalid share code: size out of range.");
            return;
        }
        if (bits.length() != w * h) {
            setStatus("Invalid share code: length mismatch.");
            return;
        }
        boolean[] parsed = new boolean[w * h];
        for (int i = 0; i < parsed.length; i++) {
            char ch = bits.charAt(i);
            if (ch == '1') {
                parsed[i] = true;
            } else if (ch == '0') {
                parsed[i] = false;
            } else {
                setStatus("Invalid share code: unexpected character.");
                return;
            }
        }

        width = w;
        height = h;
        rebuildBoard();
        for (int i = 0; i < parsed.length; i++) {
            design[i] = parsed[i];
        }
        renderClues();
        renderCells();
        updateShareCode();
        updateSizeButtonStates();
        setStatus("Loaded design from code.");
    }

    // ==== pure puzzle logic ====

    static int[] computeClue(boolean[] line) {
        int len = line.length;
        int[] tmp = new int[len];
        int runCount = 0;
        int current = 0;
        for (int i = 0; i < len; i++) {
            if (line[i]) {
                current++;
            } else if (current > 0) {
                tmp[runCount] = current;
                runCount++;
                current = 0;
            }
        }
        if (current > 0) {
            tmp[runCount] = current;
            runCount++;
        }
        if (runCount == 0) {
            return new int[]{0};
        }
        int[] result = new int[runCount];
        for (int i = 0; i < runCount; i++) {
            result[i] = tmp[i];
        }
        return result;
    }

    static int[][] computeAllRowClues(boolean[] design, int width, int height) {
        int[][] clues = new int[height][];
        boolean[] line = new boolean[width];
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                line[c] = design[r * width + c];
            }
            clues[r] = computeClue(line);
        }
        return clues;
    }

    static int[][] computeAllColClues(boolean[] design, int width, int height) {
        int[][] clues = new int[width][];
        boolean[] line = new boolean[height];
        for (int c = 0; c < width; c++) {
            for (int r = 0; r < height; r++) {
                line[r] = design[r * width + c];
            }
            clues[c] = computeClue(line);
        }
        return clues;
    }

    /**
     * Forward/backward reachability line solver. Given a clue and the
     * currently-known tri-state cells, returns a new tri-state array with
     * every cell the clue logically forces (regardless of the others)
     * resolved to FILLED or EMPTY; cells that remain genuinely ambiguous
     * stay UNKNOWN.
     */
    static int[] analyzeLine(int[] clue, int length, int[] known) {
        boolean allEmpty = clue.length == 1 && clue[0] == 0;
        int blocks = allEmpty ? 0 : clue.length;
        int[] len = allEmpty ? new int[0] : clue;

        boolean[][] canReach = new boolean[length + 1][blocks + 1];
        canReach[0][0] = true;
        for (int i = 0; i < length; i++) {
            for (int j = 0; j <= blocks; j++) {
                if (!canReach[i][j]) {
                    continue;
                }
                if (known[i] != TRI_FILLED) {
                    canReach[i + 1][j] = true;
                }
                if (j < blocks) {
                    int blen = len[j];
                    int end = i + blen;
                    if (end <= length && fitsFilled(known, i, end)) {
                        if (j + 1 < blocks) {
                            int gap = end;
                            if (gap < length && known[gap] != TRI_FILLED) {
                                canReach[gap + 1][j + 1] = true;
                            }
                        } else {
                            canReach[end][j + 1] = true;
                        }
                    }
                }
            }
        }

        boolean[][] coReach = new boolean[length + 1][blocks + 1];
        coReach[length][blocks] = true;
        for (int i = length - 1; i >= 0; i--) {
            for (int j = 0; j <= blocks; j++) {
                boolean ok = known[i] != TRI_FILLED && coReach[i + 1][j];
                if (!ok && j < blocks) {
                    int blen = len[j];
                    int end = i + blen;
                    if (end <= length && fitsFilled(known, i, end)) {
                        if (j + 1 < blocks) {
                            int gap = end;
                            ok = gap < length && known[gap] != TRI_FILLED && coReach[gap + 1][j + 1];
                        } else {
                            ok = coReach[end][j + 1];
                        }
                    }
                }
                coReach[i][j] = ok;
            }
        }

        if (!canReach[length][blocks]) {
            return known.clone();
        }

        boolean[] filledPossible = new boolean[length];
        boolean[] emptyPossible = new boolean[length];

        for (int i = 0; i < length; i++) {
            if (known[i] == TRI_FILLED) {
                continue;
            }
            for (int j = 0; j <= blocks; j++) {
                if (canReach[i][j] && coReach[i + 1][j]) {
                    emptyPossible[i] = true;
                    break;
                }
            }
        }

        for (int i = 0; i < length; i++) {
            for (int j = 0; j < blocks; j++) {
                if (!canReach[i][j]) {
                    continue;
                }
                int blen = len[j];
                int end = i + blen;
                if (end > length || !fitsFilled(known, i, end)) {
                    continue;
                }
                boolean completes;
                if (j + 1 < blocks) {
                    int gap = end;
                    completes = gap < length && known[gap] != TRI_FILLED && coReach[gap + 1][j + 1];
                } else {
                    completes = coReach[end][j + 1];
                }
                if (completes) {
                    for (int c = i; c < end; c++) {
                        filledPossible[c] = true;
                    }
                    if (j + 1 < blocks) {
                        emptyPossible[end] = true;
                    }
                }
            }
        }

        int[] result = known.clone();
        for (int i = 0; i < length; i++) {
            if (result[i] != TRI_UNKNOWN) {
                continue;
            }
            boolean f = filledPossible[i];
            boolean e = emptyPossible[i];
            if (f && !e) {
                result[i] = TRI_FILLED;
            } else if (e && !f) {
                result[i] = TRI_EMPTY;
            }
        }
        return result;
    }

    static boolean fitsFilled(int[] known, int start, int end) {
        for (int c = start; c < end; c++) {
            if (known[c] == TRI_EMPTY) {
                return false;
            }
        }
        return true;
    }

    static int[] solveGrid(boolean[] design, int width, int height) {
        int n = width * height;
        int[] master = new int[n];
        for (int i = 0; i < n; i++) {
            master[i] = TRI_UNKNOWN;
        }

        int[][] rowClues = computeAllRowClues(design, width, height);
        int[][] colClues = computeAllColClues(design, width, height);

        boolean changed = true;
        int guard = 0;
        int maxGuard = width * height + 1;
        while (changed && guard < maxGuard) {
            changed = false;
            guard++;

            for (int r = 0; r < height; r++) {
                int[] known = new int[width];
                for (int c = 0; c < width; c++) {
                    known[c] = master[r * width + c];
                }
                int[] updated = analyzeLine(rowClues[r], width, known);
                for (int c = 0; c < width; c++) {
                    if (updated[c] != known[c]) {
                        master[r * width + c] = updated[c];
                        changed = true;
                    }
                }
            }

            for (int c = 0; c < width; c++) {
                int[] known = new int[height];
                for (int r = 0; r < height; r++) {
                    known[r] = master[r * width + c];
                }
                int[] updated = analyzeLine(colClues[c], height, known);
                for (int r = 0; r < height; r++) {
                    if (updated[r] != known[r]) {
                        master[r * width + c] = updated[r];
                        changed = true;
                    }
                }
            }
        }

        return master;
    }

    static int countUnknown(int[] master) {
        int count = 0;
        for (int v : master) {
            if (v == TRI_UNKNOWN) {
                count++;
            }
        }
        return count;
    }

    static boolean hasAnyFilled(boolean[] cells) {
        for (boolean cell : cells) {
            if (cell) {
                return true;
            }
        }
        return false;
    }

    static int[] findSingleFix(boolean[] design, int width, int height) {
        int n = width * height;
        for (int idx = 0; idx < n; idx++) {
            design[idx] = !design[idx];
            int[] result = solveGrid(design, width, height);
            design[idx] = !design[idx];
            if (countUnknown(result) == 0) {
                return new int[]{idx / width, idx % width};
            }
        }
        return null;
    }

    static int[] findDoubleFix(boolean[] design, int width, int height, int[] master) {
        boolean[] ambigRow = new boolean[height];
        boolean[] ambigCol = new boolean[width];
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if (master[r * width + c] == TRI_UNKNOWN) {
                    ambigRow[r] = true;
                    ambigCol[c] = true;
                }
            }
        }

        int[] candidates = new int[width * height];
        int candidateCount = 0;
        // Unknown cells first — they are the actual ambiguity — so capping
        // never drops them in favor of merely-in-the-same-row/column cells.
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if (master[r * width + c] == TRI_UNKNOWN) {
                    candidates[candidateCount] = r * width + c;
                    candidateCount++;
                }
            }
        }
        for (int r = 0; r < height; r++) {
            for (int c = 0; c < width; c++) {
                if ((ambigRow[r] || ambigCol[c]) && master[r * width + c] != TRI_UNKNOWN) {
                    candidates[candidateCount] = r * width + c;
                    candidateCount++;
                }
            }
        }
        if (candidateCount > DOUBLE_FIX_CANDIDATE_CAP) {
            candidateCount = DOUBLE_FIX_CANDIDATE_CAP;
        }

        for (int i = 0; i < candidateCount; i++) {
            for (int j = i + 1; j < candidateCount; j++) {
                int a = candidates[i];
                int b = candidates[j];
                design[a] = !design[a];
                design[b] = !design[b];
                int[] result = solveGrid(design, width, height);
                design[a] = !design[a];
                design[b] = !design[b];
                if (countUnknown(result) == 0) {
                    return new int[]{a / width, a % width, b / width, b % width};
                }
            }
        }
        return null;
    }

    // ==== exported pure functions (unit-testable from compiled app.mjs) ====

    @JSExport
    public static String clueOf(String lineBits) {
        int len = lineBits.length();
        boolean[] line = new boolean[len];
        for (int i = 0; i < len; i++) {
            line[i] = lineBits.charAt(i) == '1';
        }
        int[] clue = computeClue(line);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < clue.length; i++) {
            if (i > 0) {
                sb.append(',');
            }
            sb.append(clue[i]);
        }
        return sb.toString();
    }

    @JSExport
    public static String solveLine(String clueCsv, int length) {
        int[] clue = parseClueCsv(clueCsv);
        int[] known = new int[length];
        for (int i = 0; i < length; i++) {
            known[i] = TRI_UNKNOWN;
        }
        int[] result = analyzeLine(clue, length, known);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            if (result[i] == TRI_FILLED) {
                sb.append('F');
            } else if (result[i] == TRI_EMPTY) {
                sb.append('E');
            } else {
                sb.append('?');
            }
        }
        return sb.toString();
    }

    static int[] parseClueCsv(String csv) {
        if (csv == null || csv.length() == 0) {
            return new int[]{0};
        }
        String[] parts = csv.split(",");
        int[] clue = new int[parts.length];
        for (int i = 0; i < parts.length; i++) {
            clue[i] = Integer.parseInt(parts[i].trim());
        }
        return clue;
    }

    @JSExport
    public static int countAmbiguousCells(String designBits, int w, int h) {
        boolean[] d = new boolean[w * h];
        for (int i = 0; i < d.length; i++) {
            d[i] = designBits.charAt(i) == '1';
        }
        int[] result = solveGrid(d, w, h);
        return countUnknown(result);
    }

    @JSExport
    public static String suggestFixFor(String designBits, int w, int h) {
        boolean[] d = new boolean[w * h];
        for (int i = 0; i < d.length; i++) {
            d[i] = designBits.charAt(i) == '1';
        }
        int[] result = solveGrid(d, w, h);
        if (countUnknown(result) == 0) {
            return "NONE";
        }
        int[] single = findSingleFix(d, w, h);
        if (single != null) {
            return "FLIP:" + single[0] + "," + single[1];
        }
        int[] pair = findDoubleFix(d, w, h, result);
        if (pair != null) {
            return "FLIP:" + pair[0] + "," + pair[1] + "," + pair[2] + "," + pair[3];
        }
        return "NOFIX";
    }
}
