"""Offline, independent classic-rule BFS for an editorial research question.

No game-engine/solver import. Exact tuple keys identify bottle permutations;
representative boards retain physical bottle indices for replayable witnesses.
A state ceiling is inconclusive, never an unsolvability claim.
"""
from __future__ import annotations

import argparse
from collections import deque
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import time

Board = tuple[tuple[int, ...], ...]


def key(board: Board) -> Board:
    return tuple(sorted(board))


def finished(board: Board) -> bool:
    return all(not tube or len(tube) == 4 and len(set(tube)) == 1 for tube in board)


def successors(board: Board):
    for source, tube in enumerate(board):
        if not tube:
            continue
        color = tube[-1]
        run = 1
        while run < len(tube) and tube[-run - 1] == color:
            run += 1
        for target, receiving in enumerate(board):
            if source == target or len(receiving) == 4 or receiving and receiving[-1] != color:
                continue
            amount = min(run, 4 - len(receiving))
            next_board = list(board)
            next_board[source] = tube[:-amount]
            next_board[target] = receiving + (color,) * amount
            yield tuple(next_board), (source + 1, target + 1, amount)


def solve(board: Board, minimum_empty: int, max_states: int) -> dict:
    start = time.perf_counter()
    initial_key = key(board)
    parents: dict[Board, tuple[Board, tuple[int, int, int]] | None] = {initial_key: None}
    queue = deque([(board, 0)])
    expanded = 0
    while queue:
        current, depth = queue.popleft()
        current_key = key(current)
        expanded += 1
        if finished(current):
            path = []
            cursor = current_key
            while parents[cursor] is not None:
                previous, move = parents[cursor]
                path.append(move)
                cursor = previous
            path.reverse()
            return {"status": "solved", "pours": depth, "path": path, "visited": len(parents), "expanded": expanded, "seconds": round(time.perf_counter() - start, 3)}
        for candidate, move in successors(current):
            if sum(not tube for tube in candidate) < minimum_empty:
                continue
            candidate_key = key(candidate)
            if candidate_key in parents:
                continue
            if len(parents) >= max_states:
                return {"status": "state-limit-inconclusive", "visited": len(parents), "expanded": expanded, "seconds": round(time.perf_counter() - start, 3)}
            parents[candidate_key] = (current_key, move)
            queue.append((candidate, depth + 1))
    return {"status": "exhaustive-no-solution", "visited": len(parents), "expanded": expanded, "seconds": round(time.perf_counter() - start, 3)}


def replay(board: Board, path: list, minimum_empty: int) -> list:
    states = [board]
    for source, target, amount in path:
        choices = [(candidate, move) for candidate, move in successors(board) if move == (source, target, amount)]
        assert len(choices) == 1, (board, source, target, amount)
        board = choices[0][0]
        assert sum(not tube for tube in board) >= minimum_empty
        states.append(board)
    assert finished(board)
    return states


def validate_bank(levels) -> None:
    if not isinstance(levels, list) or len(levels) != 24:
        raise ValueError("This audit expects the published bank of exactly 24 levels.")
    for number, level in enumerate(levels, 1):
        tubes = level.get("tubes") if isinstance(level, dict) else None
        if not isinstance(tubes, list) or not 4 <= len(tubes) <= 8:
            raise ValueError(f"Level {number}: expected four to eight bottles.")
        colors = len(tubes) - 2
        if any(not isinstance(tube, list) or len(tube) not in (0, 4) for tube in tubes):
            raise ValueError(f"Level {number}: starting bottles must be empty or have four layers.")
        if sum(not tube for tube in tubes) != 2:
            raise ValueError(f"Level {number}: expected exactly two supplied empty bottles.")
        units = [color for tube in tubes for color in tube]
        if any(type(color) is not int or not 0 <= color < colors for color in units):
            raise ValueError(f"Level {number}: colors must be integer IDs from zero to {colors - 1}.")
        if any(units.count(color) != 4 for color in range(colors)):
            raise ValueError(f"Level {number}: each color must occur exactly four times.")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", default="levels.json", help="Downloaded 24-level input JSON")
    parser.add_argument("--max-states", type=int, default=100000)
    parser.add_argument("--output", default="results.json")
    args = parser.parse_args()
    if args.max_states < 1:
        parser.error("--max-states must be positive")
    source = Path(args.input)
    try:
        levels = json.loads(source.read_text(encoding="utf-8"))
        validate_bank(levels)
    except (OSError, ValueError) as error:
        parser.error(str(error))
    # Ground rules checked without a production-engine call.
    opening = ((0, 0, 1, 1), (1, 1, 0, 0), (), ())
    assert dict((move[:2], candidate) for candidate, move in successors(opening))[(1, 3)] == ((0, 0), (1, 1, 0, 0), (1, 1), ())
    assert not any(move[:2] == (1, 2) for _, move in successors(opening))
    partial = ((0, 1, 1), (1, 1, 1), (0, 0, 0), ())
    assert any(move == (1, 2, 1) and candidate[0] == (0, 1) for candidate, move in successors(partial))
    assert not finished(((0, 0), (0, 0), (1, 1, 1, 1), ()))
    report = {
        "createdAtUtc": datetime.now(timezone.utc).isoformat(),
        "scope": "Puzzle Perch's 24 fixed classic Water Sort levels, four layers per color, capacity four, two supplied empty bottles. Does not describe other apps or random deals.",
        "method": "Independent breadth-first enumeration of every legal whole-top-run pour, capped by free destination space. Exact canonical tuple keys collapse only interchangeable bottle positions. No heuristic solver pruning.",
        "boardDataSha256": hashlib.sha256(json.dumps([level["tubes"] for level in levels], separators=(",", ":")).encode("utf-8")).hexdigest(),
        "hashDefinition": "SHA-256 of compact UTF-8 JSON of the array of 24 starting bottle arrays, with bottom-to-top color IDs; independent of line endings and object formatting.",
        "maxStatesPerSearch": args.max_states,
        "levels": [],
    }
    for number, level in enumerate(levels, 1):
        board = tuple(tuple(tube) for tube in level["tubes"])
        row = {"level": number, "colors": len(board) - 2, "startingBottles": level["tubes"]}
        for name, minimum in [("normal", 0), ("keepOneEmpty", 1)]:
            result = solve(board, minimum, args.max_states)
            if result["status"] == "solved":
                states = replay(board, result["path"], minimum)
                result["minimumEmptyOnWitness"] = min(sum(not tube for tube in state) for state in states)
            row[name] = result
        report["levels"].append(row)
        Path(args.output).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
        print(f"Level {number}: normal={row['normal']['status']} {row['normal'].get('pours', '')}; keep-one={row['keepOneEmpty']['status']} {row['keepOneEmpty'].get('pours', '')}", flush=True)


if __name__ == "__main__":
    main()
