import { useEffect, useRef, useState } from "react";

import type { PuzzleType } from "../lib/api";

type Props = {
  puzzleType: PuzzleType;
  problemAscii: string;
  boardAscii: string;
  onChange: (nextAscii: string) => void;
};

type BridgesOpportunity = {
  id: string;
  kind: "horizontal" | "vertical";
  cells: Array<[number, number]>;
  from: [number, number];
  to: [number, number];
};

type LoopyEdge = {
  id: string;
  kind: "horizontal" | "vertical";
  row: number;
  col: number;
  boardRow: number;
  boardCol: number;
};

type GalaxyBoundary = {
  id: string;
  kind: "horizontal" | "vertical";
  row: number;
  col: number;
  boardRow: number;
  boardCol: number;
};

type GalaxyDot = {
  rowCoord: number;
  colCoord: number;
};

type PatternRow = {
  prefix: string;
  cells: string[];
  border: string;
};

type UndeadRow = {
  left: string;
  cells: string[];
  right: string;
};

const FLOW_FREE_COLORS = [
  "#f15b5d",
  "#f5a623",
  "#f6df5a",
  "#7ed957",
  "#36cfc9",
  "#4b8ef7",
  "#7a5cff",
  "#ff8bd1",
  "#7d5a50",
  "#5f6a6b",
];

function splitLines(board: string): string[] {
  return board.replace(/\r/g, "").split("\n");
}

function cloneGrid(lines: string[]): string[][] {
  return lines.map((line) => [...line]);
}

function joinGrid(grid: string[][]): string {
  return grid.map((row) => row.join("")).join("\n");
}

function padLines(lines: string[], width: number): string[] {
  return lines.map((line) => line.padEnd(width, " "));
}

function isClueChar(char: string): boolean {
  return /[0-9A-G]/.test(char);
}

function addBridgeOpportunityForCell(
  cellToOpportunities: Map<string, BridgesOpportunity[]>,
  r: number,
  c: number,
  opportunity: BridgesOpportunity,
) {
  const key = `${r}:${c}`;
  cellToOpportunities.set(key, [...(cellToOpportunities.get(key) ?? []), opportunity]);
}

function bridgeSymbol(opportunity: BridgesOpportunity, level: 1 | 2): string {
  return opportunity.kind === "horizontal" ? (level === 2 ? "=" : "-") : level === 2 ? '"' : "|";
}

function bridgeLevelFromChar(opportunity: BridgesOpportunity, char: string): 0 | 1 | 2 {
  if (opportunity.kind === "horizontal") {
    return char === "=" ? 2 : char === "-" ? 1 : 0;
  }
  return char === '"' ? 2 : char === "|" ? 1 : 0;
}

function bridgeOpportunitiesShareCell(a: BridgesOpportunity, b: BridgesOpportunity): boolean {
  const bCells = new Set(b.cells.map(([r, c]) => `${r}:${c}`));
  return a.cells.some(([r, c]) => bCells.has(`${r}:${c}`));
}

function parseBridges(problemAscii: string) {
  const lines = splitLines(problemAscii);
  const opportunities: BridgesOpportunity[] = [];
  const cellToOpportunities = new Map<string, BridgesOpportunity[]>();
  for (let r = 0; r < lines.length; r += 1) {
    for (let c = 0; c < lines[r].length; c += 1) {
      if (!isClueChar(lines[r][c])) {
        continue;
      }

      let next = c + 1;
      while (next < lines[r].length && lines[r][next] === ".") {
        next += 1;
      }
      if (next < lines[r].length && isClueChar(lines[r][next]) && next > c + 1) {
        const opp: BridgesOpportunity = {
          id: `h-${r}-${c}-${next}`,
          kind: "horizontal",
          cells: [],
          from: [r, c],
          to: [r, next],
        };
        for (let cell = c + 1; cell < next; cell += 1) {
          opp.cells.push([r, cell]);
          addBridgeOpportunityForCell(cellToOpportunities, r, cell, opp);
        }
        opportunities.push(opp);
      }

      let nextRow = r + 1;
      while (nextRow < lines.length && c < lines[nextRow].length && lines[nextRow][c] === ".") {
        nextRow += 1;
      }
      if (
        nextRow < lines.length &&
        c < lines[nextRow].length &&
        isClueChar(lines[nextRow][c]) &&
        nextRow > r + 1
      ) {
        const opp: BridgesOpportunity = {
          id: `v-${r}-${c}-${nextRow}`,
          kind: "vertical",
          cells: [],
          from: [r, c],
          to: [nextRow, c],
        };
        for (let cell = r + 1; cell < nextRow; cell += 1) {
          opp.cells.push([cell, c]);
          addBridgeOpportunityForCell(cellToOpportunities, cell, c, opp);
        }
        opportunities.push(opp);
      }
    }
  }
  return { lines, opportunities, cellToOpportunities };
}

function bridgeLevelFromCells(opportunity: BridgesOpportunity, boardLines: string[]): 0 | 1 | 2 {
  let level: 0 | 1 | 2 = 0;
  for (const [r, c] of opportunity.cells) {
    const cellLevel = bridgeLevelFromChar(opportunity, boardLines[r]?.[c] ?? ".");
    if (cellLevel === 2) {
      return 2;
    }
    if (cellLevel === 1) {
      level = 1;
    }
  }
  return level;
}

function sortedBridgeCandidates(opportunities: BridgesOpportunity[]): BridgesOpportunity[] {
  return [...opportunities].sort((a, b) => {
    if (a.kind !== b.kind) {
      return a.kind === "horizontal" ? -1 : 1;
    }
    return a.id.localeCompare(b.id);
  });
}

function clearBridgeConflicts(
  parsed: ReturnType<typeof parseBridges>,
  states: Record<string, 0 | 1 | 2>,
  selected: BridgesOpportunity,
) {
  for (const opportunity of parsed.opportunities) {
    if (opportunity.id !== selected.id && bridgeOpportunitiesShareCell(opportunity, selected)) {
      states[opportunity.id] = 0;
    }
  }
}

function bridgeClickCountForOpportunity(
  opportunities: BridgesOpportunity[],
  opportunity: BridgesOpportunity,
  level: 0 | 1 | 2,
): number {
  if (level === 0 || opportunities.length <= 1) {
    return level;
  }
  const index = sortedBridgeCandidates(opportunities).findIndex(
    (candidate) => candidate.id === opportunity.id,
  );
  return index === -1 ? level : index * 2 + level;
}

function cycleBridgesOpportunities(
  parsed: ReturnType<typeof parseBridges>,
  states: Record<string, 0 | 1 | 2>,
  opportunities: BridgesOpportunity[],
): Record<string, 0 | 1 | 2> {
  if (opportunities.length === 0) {
    return states;
  }

  const nextStates = { ...states };
  const candidates = sortedBridgeCandidates(opportunities);

  if (candidates.length === 1) {
    const opportunity = candidates[0];
    const level = (((nextStates[opportunity.id] ?? 0) + 1) % 3) as 0 | 1 | 2;
    nextStates[opportunity.id] = level;
    if (level > 0) {
      clearBridgeConflicts(parsed, nextStates, opportunity);
    }
    return nextStates;
  }

  const sequence: Array<{ opportunity: BridgesOpportunity; level: 1 | 2 } | null> = [
    ...candidates.flatMap((opportunity) => [
      { opportunity, level: 1 as const },
      { opportunity, level: 2 as const },
    ]),
    null,
  ];
  const currentIndex = sequence.findIndex((entry) =>
    candidates.every((opportunity) => {
      const expected = entry?.opportunity.id === opportunity.id ? entry.level : 0;
      return (nextStates[opportunity.id] ?? 0) === expected;
    }),
  );
  const fallbackIndex = sequence.findIndex(
    (entry) => entry !== null && (nextStates[entry.opportunity.id] ?? 0) === entry.level,
  );
  const safeCurrentIndex =
    currentIndex === -1 ? (fallbackIndex === -1 ? sequence.length - 1 : fallbackIndex) : currentIndex;

  const nextEntry = sequence[(safeCurrentIndex + 1) % sequence.length];
  for (const opportunity of candidates) {
    nextStates[opportunity.id] = 0;
  }
  if (nextEntry) {
    nextStates[nextEntry.opportunity.id] = nextEntry.level;
    clearBridgeConflicts(parsed, nextStates, nextEntry.opportunity);
  }
  return nextStates;
}

function bridgesBoardFromStates(
  problemAscii: string,
  states: Record<string, 0 | 1 | 2>,
): string {
  const parsed = parseBridges(problemAscii);
  const grid = cloneGrid(parsed.lines);
  for (const opportunity of parsed.opportunities) {
    const level = states[opportunity.id] ?? 0;
    // The grid starts as a fresh copy of the problem (non-clue cells are "."),
    // so a level-0 opportunity has nothing to add. Skip it: writing "." here
    // would clobber the crossing cell of an *active* perpendicular bridge that
    // shares this cell (e.g. a vertical bridge passing through a cell also owned
    // by an unused horizontal opportunity), leaving a gap in the drawn bridge.
    if (level === 0) {
      continue;
    }
    const symbol = bridgeSymbol(opportunity, level);
    for (const [r, c] of opportunity.cells) {
      grid[r][c] = symbol;
    }
  }
  return joinGrid(grid);
}

function bridgesStatesFromBoard(
  problemAscii: string,
  boardAscii: string,
): Record<string, 0 | 1 | 2> {
  const parsed = parseBridges(problemAscii);
  const boardLines = splitLines(boardAscii);
  const states: Record<string, 0 | 1 | 2> = {};
  for (const opportunity of parsed.opportunities) {
    states[opportunity.id] = bridgeLevelFromCells(opportunity, boardLines);
  }
  return states;
}

function parseLoopy(problemAscii: string) {
  const lines = splitLines(problemAscii);
  const rows = Math.max(0, Math.floor((lines.length - 3) / 2));
  const cols = Math.max(0, Math.floor(((lines[0]?.length ?? 0) - 3) / 2));
  const clues = new Map<string, string>();
  const horizontalEdges: LoopyEdge[] = [];
  const verticalEdges: LoopyEdge[] = [];

  for (let r = 0; r < rows; r += 1) {
    for (let c = 0; c < cols; c += 1) {
      const char = lines[2 + 2 * r]?.[2 + 2 * c] ?? " ";
      if (/\d/.test(char)) {
        clues.set(`${r}:${c}`, char);
      }
    }
  }

  for (let r = 0; r <= rows; r += 1) {
    for (let c = 0; c < cols; c += 1) {
      horizontalEdges.push({
        id: `h-${r}-${c}`,
        kind: "horizontal",
        row: r,
        col: c,
        boardRow: 1 + 2 * r,
        boardCol: 2 + 2 * c,
      });
    }
  }

  for (let r = 0; r < rows; r += 1) {
    for (let c = 0; c <= cols; c += 1) {
      verticalEdges.push({
        id: `v-${r}-${c}`,
        kind: "vertical",
        row: r,
        col: c,
        boardRow: 2 + 2 * r,
        boardCol: 1 + 2 * c,
      });
    }
  }

  return { lines, rows, cols, clues, horizontalEdges, verticalEdges };
}

function loopyEdgeStates(
  problemAscii: string,
  boardAscii: string,
): Record<string, "unknown" | "line" | "blocked"> {
  const puzzle = parseLoopy(problemAscii);
  const width = puzzle.lines[0]?.length ?? 0;
  const boardLines = padLines(splitLines(boardAscii), width);
  const states: Record<string, "unknown" | "line" | "blocked"> = {};

  for (const edge of [...puzzle.horizontalEdges, ...puzzle.verticalEdges]) {
    const char = boardLines[edge.boardRow]?.[edge.boardCol] ?? " ";
    states[edge.id] = char === "-" || char === "|" ? "line" : char === "x" ? "blocked" : "unknown";
  }
  return states;
}

function loopyBoardFromStates(
  problemAscii: string,
  states: Record<string, "unknown" | "line" | "blocked">,
): string {
  const puzzle = parseLoopy(problemAscii);
  const width = puzzle.lines[0]?.length ?? 0;
  const grid = cloneGrid(padLines(puzzle.lines, width));

  for (const edge of puzzle.horizontalEdges) {
    const state = states[edge.id] ?? "blocked";
    grid[edge.boardRow][edge.boardCol] = state === "line" ? "-" : state === "blocked" ? "x" : " ";
  }
  for (const edge of puzzle.verticalEdges) {
    const state = states[edge.id] ?? "blocked";
    grid[edge.boardRow][edge.boardCol] = state === "line" ? "|" : state === "blocked" ? "x" : " ";
  }

  return joinGrid(grid);
}

function parseGalaxies(problemAscii: string) {
  const lines = splitLines(problemAscii);
  const rows = Math.max(0, Math.floor(((lines.length ?? 0) - 1) / 2));
  const cols = Math.max(0, Math.floor(((lines[0]?.length ?? 0) - 1) / 2));
  const horizontalBoundaries: GalaxyBoundary[] = [];
  const verticalBoundaries: GalaxyBoundary[] = [];
  const dots: GalaxyDot[] = [];

  for (let boardRow = 0; boardRow < lines.length; boardRow += 1) {
    const line = lines[boardRow] ?? "";
    for (let boardCol = 0; boardCol < line.length; boardCol += 1) {
      if (line[boardCol] === "o") {
        dots.push({ rowCoord: boardRow, colCoord: boardCol });
      }
    }
  }

  for (let r = 1; r < rows; r += 1) {
    for (let c = 0; c < cols; c += 1) {
      if ((lines[2 * r]?.[1 + 2 * c] ?? " ") === "o") {
        continue;
      }
      horizontalBoundaries.push({
        id: `h-${r}-${c}`,
        kind: "horizontal",
        row: r,
        col: c,
        boardRow: 2 * r,
        boardCol: 1 + 2 * c,
      });
    }
  }

  for (let r = 0; r < rows; r += 1) {
    for (let c = 1; c < cols; c += 1) {
      if ((lines[1 + 2 * r]?.[2 * c] ?? " ") === "o") {
        continue;
      }
      verticalBoundaries.push({
        id: `v-${r}-${c}`,
        kind: "vertical",
        row: r,
        col: c,
        boardRow: 1 + 2 * r,
        boardCol: 2 * c,
      });
    }
  }

  return { lines, rows, cols, horizontalBoundaries, verticalBoundaries, dots };
}

function galaxiesBoundaryStates(
  problemAscii: string,
  boardAscii: string,
): Record<string, boolean> {
  const puzzle = parseGalaxies(problemAscii);
  const width = puzzle.lines[0]?.length ?? 0;
  const boardLines = padLines(splitLines(boardAscii), width);
  const states: Record<string, boolean> = {};
  for (const boundary of [...puzzle.horizontalBoundaries, ...puzzle.verticalBoundaries]) {
    const char = boardLines[boundary.boardRow]?.[boundary.boardCol] ?? " ";
    states[boundary.id] = char === "-" || char === "|";
  }
  return states;
}

function galaxiesBoardFromStates(problemAscii: string, states: Record<string, boolean>): string {
  const puzzle = parseGalaxies(problemAscii);
  const width = puzzle.lines[0]?.length ?? 0;
  const grid = cloneGrid(padLines(puzzle.lines, width));

  for (const boundary of puzzle.horizontalBoundaries) {
    grid[boundary.boardRow][boundary.boardCol] = states[boundary.id] ? "-" : " ";
  }
  for (const boundary of puzzle.verticalBoundaries) {
    grid[boundary.boardRow][boundary.boardCol] = states[boundary.id] ? "|" : " ";
  }

  return joinGrid(grid);
}

function parsePattern(boardAscii: string) {
  const lines = splitLines(boardAscii);
  const firstContentIndex = lines.findIndex((line) => line.includes("|"));
  const topLines = lines.slice(0, firstContentIndex);
  const rows: PatternRow[] = [];
  for (let idx = firstContentIndex; idx < lines.length; idx += 2) {
    const content = lines[idx];
    const border = lines[idx + 1];
    if (!content || !border) {
      break;
    }
    const firstBar = content.indexOf("|");
    const prefix = content.slice(0, firstBar);
    const parts = content.slice(firstBar).split("|").slice(1, -1);
    rows.push({ prefix, cells: parts, border });
  }
  return { topLines, rows };
}

function normalizePatternAscii(boardAscii: string) {
  const template = parsePattern(boardAscii);
  let changed = false;
  for (const row of template.rows) {
    for (let index = 0; index < row.cells.length; index += 1) {
      if (row.cells[index] === "  ") {
        row.cells[index] = "..";
        changed = true;
      }
    }
  }
  return changed ? patternToAscii(template) : boardAscii;
}

function parsePatternColumnClues(template: ReturnType<typeof parsePattern>): string[][] {
  const topBorder = template.topLines.find((line) => line.includes("+"));
  const clueLines = template.topLines.filter((line) => !line.includes("+"));
  const firstRow = template.rows[0];
  if (!topBorder || !firstRow) {
    return [];
  }

  const firstBar = topBorder.indexOf("+");
  const columnCount = firstRow.cells.length;
  const columns: string[][] = Array.from({ length: columnCount }, () => []);

  for (const line of clueLines) {
    for (let col = 0; col < columnCount; col += 1) {
      const cellSlice = line.slice(firstBar + 1 + col * 3, firstBar + 3 + col * 3).trim();
      if (cellSlice) {
        columns[col].push(cellSlice);
      }
    }
  }

  return columns;
}

function patternToAscii(template: ReturnType<typeof parsePattern>): string {
  const lines = [...template.topLines];
  for (const row of template.rows) {
    lines.push(`${row.prefix}|${row.cells.join("|")}|`);
    lines.push(row.border);
  }
  return lines.join("\n");
}

function parseUndead(boardAscii: string) {
  const lines = splitLines(boardAscii);
  const header = lines[0] ?? "";
  const topLine = lines[2] ?? "";
  const bottomLine = lines.at(-1) ?? "";
  const rows: UndeadRow[] = lines.slice(3, -1).map((line) => {
    const tokens = line.trim().split(/\s+/);
    return {
      left: tokens[0] ?? "",
      cells: tokens.slice(1, -1),
      right: tokens.at(-1) ?? "",
    };
  });
  return { header, topLine, bottomLine, rows };
}

function undeadToAscii(template: ReturnType<typeof parseUndead>): string {
  const lines = [template.header, "", template.topLine];
  for (const row of template.rows) {
    lines.push(` ${row.left} ${row.cells.join(" ")} ${row.right}`);
  }
  lines.push(template.bottomLine);
  return lines.join("\n");
}

function legendColor(letter: string) {
  return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length];
}

function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const parsed = parseBridges(problemAscii);
  const [states, setStates] = useState(() => bridgesStatesFromBoard(problemAscii, boardAscii));
  const lastEmittedBoard = useRef<string | null>(null);
  const renderedBoardAscii = bridgesBoardFromStates(problemAscii, states);
  const boardLines = splitLines(renderedBoardAscii);

  useEffect(() => {
    if (lastEmittedBoard.current === boardAscii) {
      return;
    }
    setStates(bridgesStatesFromBoard(problemAscii, boardAscii));
    lastEmittedBoard.current = null;
  }, [problemAscii, boardAscii]);

  function cycle(opportunities: BridgesOpportunity[]) {
    const nextStates = cycleBridgesOpportunities(parsed, states, opportunities);
    const nextBoard = bridgesBoardFromStates(problemAscii, nextStates);
    setStates(nextStates);
    lastEmittedBoard.current = nextBoard;
    onChange(nextBoard);
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Click a route between islands to cycle no bridge, single bridge, and double bridge.</div>
      <div
        className="board-grid bridges-grid"
        style={{ gridTemplateColumns: `repeat(${parsed.lines[0]?.length ?? 0}, 40px)` }}
      >
        {parsed.lines.flatMap((line, r) =>
          [...line].map((char, c) => {
            const opportunities = parsed.cellToOpportunities.get(`${r}:${c}`) ?? [];
            const boardChar = boardLines[r]?.[c] ?? char;
            const className = isClueChar(char)
              ? "board-cell fixed island-cell"
              : opportunities.length > 0
                ? "board-cell bridge-cell"
                : "board-cell water";
            const display =
              boardChar === "." ? "" : boardChar === '"' ? "‖" : boardChar === "=" ? "═" : boardChar;
            return (
              <button
                key={`${r}-${c}`}
                type="button"
                className={className}
                onClick={() => cycle(opportunities)}
                disabled={opportunities.length === 0}
              >
                {isClueChar(char) ? char : display}
              </button>
            );
          }),
        )}
      </div>
    </div>
  );
}

function FlowFreeEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const problemLines = splitLines(problemAscii);
  const boardLines = splitLines(boardAscii);
  const letters = [...new Set(problemAscii.replace(/[^A-Z]/g, "").split(""))].sort();
  const [activeColor, setActiveColor] = useState(letters[0] ?? "A");

  function updateCell(r: number, c: number) {
    if (problemLines[r][c] !== ".") {
      setActiveColor(problemLines[r][c]);
      return;
    }
    const grid = cloneGrid(boardLines);
    grid[r][c] = grid[r][c] === activeColor ? "." : activeColor;
    onChange(joinGrid(grid));
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Pick a color, then fill open cells. Clicking an endpoint picks that color.</div>
      <div className="palette">
        {letters.map((letter) => (
          <button
            key={letter}
            type="button"
            className={activeColor === letter ? "active" : ""}
            style={{ background: legendColor(letter) }}
            onClick={() => setActiveColor(letter)}
          >
            {letter}
          </button>
        ))}
        <button
          type="button"
          className={activeColor === "." ? "active erase-swatch" : "erase-swatch"}
          onClick={() => setActiveColor(".")}
        >
          Erase
        </button>
      </div>
      <div
        className="board-grid flow-grid"
        style={{ gridTemplateColumns: `repeat(${problemLines[0]?.length ?? 0}, 44px)` }}
      >
        {boardLines.flatMap((line, r) =>
          [...line].map((char, c) => {
            const fixed = problemLines[r][c] !== ".";
            const letter = fixed ? problemLines[r][c] : char;
            const color = letter === "." ? "transparent" : legendColor(letter);
            return (
              <button
                key={`${r}-${c}`}
                type="button"
                className={`board-cell flow-cell ${fixed ? "fixed endpoint-cell" : "water"}`}
                style={{ background: letter === "." ? undefined : `${color}${fixed ? "" : "d8"}` }}
                onClick={() => updateCell(r, c)}
              >
                {letter === "." ? "" : letter}
              </button>
            );
          }),
        )}
      </div>
    </div>
  );
}

function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const puzzle = parseLoopy(problemAscii);
  const states = loopyEdgeStates(problemAscii, boardAscii);
  const cell = 52;
  const margin = 28;
  const width = margin * 2 + puzzle.cols * cell;
  const height = margin * 2 + puzzle.rows * cell;

  function cycle(edge: LoopyEdge) {
    const current = states[edge.id] ?? "unknown";
    const next = current === "line" ? "blocked" : "line";
    onChange(
      loopyBoardFromStates(problemAscii, {
        ...states,
        [edge.id]: next,
      }),
    );
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Click any segment, including the outer border, to toggle between part of the loop and definitely not part of it.</div>
      <div className="svg-board-shell">
        <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Loopy board">
          {Array.from({ length: puzzle.rows + 1 }).map((_, row) =>
            Array.from({ length: puzzle.cols + 1 }).map((__, col) => (
              <circle
                key={`dot-${row}-${col}`}
                cx={margin + col * cell}
                cy={margin + row * cell}
                r={4.25}
                className="svg-vertex"
              />
            )),
          )}

          {Array.from({ length: puzzle.rows }).map((_, row) =>
            Array.from({ length: puzzle.cols }).map((__, col) => (
              <g key={`clue-${row}-${col}`}>
                <rect
                  x={margin + col * cell + 8}
                  y={margin + row * cell + 8}
                  width={cell - 16}
                  height={cell - 16}
                  rx={12}
                  className="svg-cell-bg"
                />
                {puzzle.clues.has(`${row}:${col}`) ? (
                  <text
                    x={margin + col * cell + cell / 2}
                    y={margin + row * cell + cell / 2 + 8}
                    className="svg-clue-text"
                    textAnchor="middle"
                  >
                    {puzzle.clues.get(`${row}:${col}`)}
                  </text>
                ) : null}
              </g>
            )),
          )}

          {puzzle.horizontalEdges.map((edge) => {
            const x1 = margin + edge.col * cell;
            const y = margin + edge.row * cell;
            const x2 = x1 + cell;
            const state = states[edge.id] ?? "unknown";
            return (
              <g key={edge.id} className="svg-hit-target" onClick={() => cycle(edge)}>
                <line x1={x1} y1={y} x2={x2} y2={y} className="svg-hit-line" />
                {state === "line" ? <line x1={x1} y1={y} x2={x2} y2={y} className="svg-line active" /> : null}
                {state === "unknown" ? <line x1={x1} y1={y} x2={x2} y2={y} className="svg-line ghost" /> : null}
                {state === "blocked" ? (
                  <text x={(x1 + x2) / 2} y={y + 6} className="svg-xmark" textAnchor="middle">
                    ×
                  </text>
                ) : null}
              </g>
            );
          })}

          {puzzle.verticalEdges.map((edge) => {
            const x = margin + edge.col * cell;
            const y1 = margin + edge.row * cell;
            const y2 = y1 + cell;
            const state = states[edge.id] ?? "unknown";
            return (
              <g key={edge.id} className="svg-hit-target" onClick={() => cycle(edge)}>
                <line x1={x} y1={y1} x2={x} y2={y2} className="svg-hit-line" />
                {state === "line" ? <line x1={x} y1={y1} x2={x} y2={y2} className="svg-line active" /> : null}
                {state === "unknown" ? <line x1={x} y1={y1} x2={x} y2={y2} className="svg-line ghost" /> : null}
                {state === "blocked" ? (
                  <text x={x} y={(y1 + y2) / 2 + 6} className="svg-xmark" textAnchor="middle">
                    ×
                  </text>
                ) : null}
              </g>
            );
          })}
        </svg>
      </div>
    </div>
  );
}

function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const puzzle = parseGalaxies(problemAscii);
  const states = galaxiesBoundaryStates(problemAscii, boardAscii);
  const cell = 56;
  const half = cell / 2;
  const margin = 28;
  const width = margin * 2 + puzzle.cols * cell;
  const height = margin * 2 + puzzle.rows * cell;

  function toggle(boundary: GalaxyBoundary) {
    onChange(
      galaxiesBoardFromStates(problemAscii, {
        ...states,
        [boundary.id]: !states[boundary.id],
      }),
    );
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Click an interior wall segment to add or remove it. Dots mark each galaxy center.</div>
      <div className="svg-board-shell">
        <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Galaxies board">
          {Array.from({ length: puzzle.rows }).map((_, row) =>
            Array.from({ length: puzzle.cols }).map((__, col) => (
              <rect
                key={`cell-${row}-${col}`}
                x={margin + col * cell}
                y={margin + row * cell}
                width={cell}
                height={cell}
                rx={14}
                className="galaxy-cell-bg"
              />
            )),
          )}

          {Array.from({ length: puzzle.rows + 1 }).map((_, row) =>
            Array.from({ length: puzzle.cols + 1 }).map((__, col) => (
              <circle
                key={`vertex-${row}-${col}`}
                cx={margin + col * cell}
                cy={margin + row * cell}
                r={3.6}
                className="svg-vertex"
              />
            )),
          )}

          {Array.from({ length: puzzle.rows + 1 }).map((_, row) =>
            Array.from({ length: puzzle.cols }).map((__, col) => (
              <line
                key={`outer-h-${row}-${col}`}
                x1={margin + col * cell}
                y1={margin + row * cell}
                x2={margin + (col + 1) * cell}
                y2={margin + row * cell}
                className={`svg-wall ${row === 0 || row === puzzle.rows ? "fixed" : "ghost"}`}
              />
            )),
          )}

          {Array.from({ length: puzzle.rows }).map((_, row) =>
            Array.from({ length: puzzle.cols + 1 }).map((__, col) => (
              <line
                key={`outer-v-${row}-${col}`}
                x1={margin + col * cell}
                y1={margin + row * cell}
                x2={margin + col * cell}
                y2={margin + (row + 1) * cell}
                className={`svg-wall ${col === 0 || col === puzzle.cols ? "fixed" : "ghost"}`}
              />
            )),
          )}

          {puzzle.horizontalBoundaries.map((boundary) => {
            const x1 = margin + boundary.col * cell;
            const y = margin + boundary.row * cell;
            const x2 = x1 + cell;
            return (
              <g key={boundary.id} className="svg-hit-target" onClick={() => toggle(boundary)}>
                <line x1={x1} y1={y} x2={x2} y2={y} className="svg-hit-line" />
                <line x1={x1} y1={y} x2={x2} y2={y} className={`svg-wall ${states[boundary.id] ? "active" : "ghost"}`} />
              </g>
            );
          })}

          {puzzle.verticalBoundaries.map((boundary) => {
            const x = margin + boundary.col * cell;
            const y1 = margin + boundary.row * cell;
            const y2 = y1 + cell;
            return (
              <g key={boundary.id} className="svg-hit-target" onClick={() => toggle(boundary)}>
                <line x1={x} y1={y1} x2={x} y2={y2} className="svg-hit-line" />
                <line x1={x} y1={y1} x2={x} y2={y2} className={`svg-wall ${states[boundary.id] ? "active" : "ghost"}`} />
              </g>
            );
          })}

          {puzzle.dots.map((dot) => (
            <circle
              key={`dot-${dot.rowCoord}-${dot.colCoord}`}
              cx={margin + dot.colCoord * half}
              cy={margin + dot.rowCoord * half}
              r={7}
              className="galaxy-dot"
            />
          ))}
        </svg>
      </div>
    </div>
  );
}

function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const normalizedBoard = normalizePatternAscii(boardAscii);

  useEffect(() => {
    if (normalizedBoard !== boardAscii) {
      onChange(normalizedBoard);
    }
  }, [boardAscii, normalizedBoard, onChange]);

  const template = parsePattern(normalizedBoard);
  const columnClues = parsePatternColumnClues(template);
  const maxColumnClueDepth = Math.max(...columnClues.map((column) => column.length), 1);

  function cycle(rowIndex: number, cellIndex: number) {
    const next = parsePattern(normalizedBoard);
    const current = next.rows[rowIndex].cells[cellIndex];
    next.rows[rowIndex].cells[cellIndex] =
      current === "##" ? ".." : "##";
    onChange(patternToAscii(next));
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Every cell starts white. Click a square to toggle it between white and filled black.</div>
      <div className="pattern-layout">
        <div className="pattern-top-spacer" />
        <div className="pattern-top-clues" style={{ gridTemplateColumns: `repeat(${columnClues.length}, 46px)` }}>
          {columnClues.map((column, index) => (
            <div
              key={`column-${index}`}
              className="pattern-column-stack"
              style={{ gridTemplateRows: `repeat(${maxColumnClueDepth}, 22px)` }}
            >
              {Array.from({ length: maxColumnClueDepth }).map((_, rowIndex) => {
                const clue = column[rowIndex - (maxColumnClueDepth - column.length)] ?? "";
                return (
                  <div key={`${index}-${rowIndex}`} className="pattern-clue-pill">
                    {clue}
                  </div>
                );
              })}
            </div>
          ))}
        </div>
        {template.rows.map((row, rowIndex) => (
          <div key={`row-wrap-${rowIndex}`} className="pattern-row-pair">
            <div className="pattern-clue pattern-row-clue">
              {row.prefix
                .trim()
                .split(/\s+/)
                .filter(Boolean)
                .map((clue, clueIndex) => (
                  <span key={`${rowIndex}-${clueIndex}`} className="pattern-clue-pill">
                    {clue}
                  </span>
                ))}
            </div>
            <div
              className="board-grid pattern-board"
              style={{ gridTemplateColumns: `repeat(${row.cells.length}, 46px)` }}
            >
              {row.cells.map((cell, cellIndex) => (
                <button
                  key={`${rowIndex}-${cellIndex}`}
                  type="button"
                  className={`board-square ${cell === "##" ? "fill" : "empty"}`}
                  onClick={() => cycle(rowIndex, cellIndex)}
                >
                  {cell === "##" ? "■" : ""}
                </button>
              ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

function UndeadEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) {
  const template = parseUndead(boardAscii);
  const topClues = template.topLine.trim().split(/\s+/);
  const bottomClues = template.bottomLine.trim().split(/\s+/);
  const boardWidth = template.rows[0]?.cells.length ?? 0;
  const rowTemplate = `40px repeat(${boardWidth}, 38px) 40px`;

  function cycle(rowIndex: number, cellIndex: number) {
    const current = template.rows[rowIndex].cells[cellIndex];
    if (current === "/" || current === "\\") {
      return;
    }
    const next = parseUndead(boardAscii);
    next.rows[rowIndex].cells[cellIndex] =
      current === "." ? "G" : current === "G" ? "V" : current === "V" ? "Z" : ".";
    onChange(undeadToAscii(next));
  }

  return (
    <div className="editor-stack">
      <div className="puzzle-note">Click a cell to cycle ghost, vampire, zombie, and blank. Mirror cells are fixed.</div>
      <div className="undead-legend">{template.header}</div>
      <div className="undead-grid-wrap">
        <div className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}>
          <div className="undead-clue-spacer" />
          {topClues.map((clue, index) => (
            <div key={`top-${index}`} className="undead-clue-box">
              {clue}
            </div>
          ))}
          <div className="undead-clue-spacer" />
        </div>

        {template.rows.map((row, rowIndex) => (
          <div key={rowIndex} className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}>
            <div className="undead-clue-box side">{row.left}</div>
            {row.cells.map((cell, cellIndex) => {
              const fixedMirror = cell === "/" || cell === "\\";
              return (
                <button
                  key={`${rowIndex}-${cellIndex}`}
                  type="button"
                  className={`board-cell undead-cell ${fixedMirror ? "mirror" : "monster-cell"}`}
                  onClick={() => cycle(rowIndex, cellIndex)}
                >
                  {cell === "." ? "" : cell}
                </button>
              );
            })}
            <div className="undead-clue-box side">{row.right}</div>
          </div>
        ))}

        <div className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}>
          <div className="undead-clue-spacer" />
          {bottomClues.map((clue, index) => (
            <div key={`bottom-${index}`} className="undead-clue-box">
              {clue}
            </div>
          ))}
          <div className="undead-clue-spacer" />
        </div>
      </div>
    </div>
  );
}

export function PuzzleEditor(props: Props) {
  if (props.puzzleType === "bridges") {
    return <BridgesEditor {...props} />;
  }
  if (props.puzzleType === "flow_free") {
    return <FlowFreeEditor {...props} />;
  }
  if (props.puzzleType === "galaxies") {
    return <GalaxiesEditor {...props} />;
  }
  if (props.puzzleType === "loopy") {
    return <LoopyEditor {...props} />;
  }
  if (props.puzzleType === "pattern") {
    return <PatternEditor {...props} />;
  }
  if (props.puzzleType === "undead") {
    return <UndeadEditor {...props} />;
  }
  return null;
}

// Test-only barrel: exposes the pure encode/decode helpers so the round-trip
// pipeline tests can exercise the *real* editor functions (not reimplementations).
// These mirror exactly what each <Family>Editor's onChange emits.
export const __testables = {
  splitLines,
  cloneGrid,
  joinGrid,
  parseBridges,
  cycleBridgesOpportunities,
  bridgeClickCountForOpportunity,
  bridgesBoardFromStates,
  bridgesStatesFromBoard,
  parseLoopy,
  loopyEdgeStates,
  loopyBoardFromStates,
  parseGalaxies,
  galaxiesBoundaryStates,
  galaxiesBoardFromStates,
  parsePattern,
  patternToAscii,
  normalizePatternAscii,
  parseUndead,
  undeadToAscii,
};
