"""BFS puzzle solver for sliding-square puzzles.

The player slides in one of 4 directions until hitting a square (or dies
if no square is reached).  Special square types modify traversal:
  - PORTAL:     pass-through when approached from the marked side
  - TELEPORTER: warp to paired teleporter on landing
  - KEY:        removes the matching LOCK square from the board
  - LOCK:       solid until its KEY is collected, then disappears
"""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from enum import Enum
import json
from pathlib import Path
from typing import Any

# ── Direction helpers ────────────────────────────────────────────────
UP = (-1, 0)
DOWN = (1, 0)
LEFT = (0, -1)
RIGHT = (0, 1)

DIRECTIONS: dict[str, tuple[int, int]] = {
    "up": UP,
    "down": DOWN,
    "left": LEFT,
    "right": RIGHT,
}

# When moving in a direction, which side of the target square do we approach?
DIR_TO_APPROACH_SIDE: dict[tuple[int, int], str] = {
    RIGHT: "left",
    LEFT: "right",
    DOWN: "up",
    UP: "down",
}


# ── Data model ───────────────────────────────────────────────────────
class SquareType(Enum):
    """Types of squares in the puzzle grid."""

    NORMAL = "normal"
    PLAYER = "player"
    GOAL = "goal"
    PORTAL = "portal"
    TELEPORTER = "teleporter"
    KEY = "key"
    LOCK = "lock"


@dataclass(frozen=True)
class Square:
    """A single square on the puzzle board."""

    pos: tuple[int, int]
    square_type: SquareType
    portal_side: str | None = None  # PORTAL: side with inner square
    teleporter_group: int | None = None  # TELEPORTER: pair id
    lock_id: int | None = None  # KEY / LOCK: matching id


@dataclass(frozen=True)
class State:
    """Immutable snapshot of player position and remaining locks."""

    pos: tuple[int, int]
    active_locks: frozenset[tuple[int, int]]


@dataclass
class _ParseMetadata:
    """Intermediate bookkeeping collected while parsing squares."""

    player_start: tuple[int, int]
    goal_pos: tuple[int, int]
    teleporter_groups: dict[int, list[tuple[int, int]]]
    key_map: dict[int, tuple[int, int]]
    lock_map: dict[int, tuple[int, int]]


def _parse_square_list(
    square_dicts: list[dict[str, Any]],
) -> tuple[dict[tuple[int, int], Square], _ParseMetadata]:
    """Parse the JSON squares list into Square objects and metadata."""
    squares: dict[tuple[int, int], Square] = {}
    player_start: tuple[int, int] | None = None
    goal_pos: tuple[int, int] | None = None
    teleporter_groups: dict[int, list[tuple[int, int]]] = {}
    key_map: dict[int, tuple[int, int]] = {}
    lock_map: dict[int, tuple[int, int]] = {}

    for sd in square_dicts:
        pos = (int(sd["pos"][0]), int(sd["pos"][1]))
        sq_type = SquareType(sd["type"])
        sq = Square(
            pos=pos,
            square_type=sq_type,
            portal_side=sd.get("side"),
            teleporter_group=sd.get("group"),
            lock_id=sd.get("lock_id"),
        )
        squares[pos] = sq

        if sq_type == SquareType.PLAYER:
            player_start = pos
        elif sq_type == SquareType.GOAL:
            goal_pos = pos
        elif sq_type == SquareType.TELEPORTER and sq.teleporter_group is not None:
            teleporter_groups.setdefault(sq.teleporter_group, []).append(pos)
        elif sq_type == SquareType.KEY and sq.lock_id is not None:
            key_map[sq.lock_id] = pos
        elif sq_type == SquareType.LOCK and sq.lock_id is not None:
            lock_map[sq.lock_id] = pos

    if player_start is None:
        msg = "No player start position found in puzzle data"
        raise ValueError(msg)
    if goal_pos is None:
        msg = "No goal position found in puzzle data"
        raise ValueError(msg)

    metadata = _ParseMetadata(
        player_start, goal_pos, teleporter_groups, key_map, lock_map
    )
    return squares, metadata


def _pair_teleporters(
    groups: dict[int, list[tuple[int, int]]],
) -> dict[tuple[int, int], tuple[int, int]]:
    """Pair up teleporter squares by group id."""
    pairs: dict[tuple[int, int], tuple[int, int]] = {}
    expected_pair_size = 2
    for gid, positions in groups.items():
        if len(positions) != expected_pair_size:
            msg = f"Teleporter group {gid} has {len(positions)} members (need 2)"
            raise ValueError(msg)
        pairs[positions[0]] = positions[1]
        pairs[positions[1]] = positions[0]
    return pairs


def _map_keys_to_locks(
    key_map: dict[int, tuple[int, int]],
    lock_map: dict[int, tuple[int, int]],
) -> dict[tuple[int, int], tuple[int, int]]:
    """Map each key position to its corresponding lock position."""
    key_to_lock: dict[tuple[int, int], tuple[int, int]] = {}
    for lid, kpos in key_map.items():
        if lid not in lock_map:
            msg = f"Key with lock_id={lid} has no matching lock"
            raise ValueError(msg)
        key_to_lock[kpos] = lock_map[lid]
    return key_to_lock


@dataclass
class Puzzle:
    """Full puzzle definition with squares, teleporters, and key-lock pairs."""

    squares: dict[tuple[int, int], Square]
    player_start: tuple[int, int]
    goal_pos: tuple[int, int]
    teleporter_pairs: dict[tuple[int, int], tuple[int, int]]
    key_to_lock: dict[tuple[int, int], tuple[int, int]]
    grid_bounds: tuple[int, int, int, int]  # min_r, max_r, min_c, max_c

    # ── JSON round-trip ──────────────────────────────────────────────
    @classmethod
    def from_json(cls, data: dict[str, Any]) -> Puzzle:
        """Build a Puzzle from a parsed JSON dict."""
        squares, metadata = _parse_square_list(data["squares"])
        teleporter_pairs = _pair_teleporters(metadata.teleporter_groups)
        key_to_lock = _map_keys_to_locks(metadata.key_map, metadata.lock_map)

        all_pos = list(squares)
        rows = [p[0] for p in all_pos]
        cols = [p[1] for p in all_pos]
        bounds = (min(rows) - 1, max(rows) + 1, min(cols) - 1, max(cols) + 1)

        return cls(
            squares,
            metadata.player_start,
            metadata.goal_pos,
            teleporter_pairs,
            key_to_lock,
            bounds,
        )

    @classmethod
    def from_file(cls, path: str) -> Puzzle:
        """Load a Puzzle from a JSON file path."""
        with Path(path).open() as f:
            return cls.from_json(json.load(f))


# ── Solver ───────────────────────────────────────────────────────────


def solve(puzzle: Puzzle) -> list[str] | None:
    """BFS over (position, active_locks) states.  Returns move list or None."""
    initial_locks = frozenset(
        sq.pos for sq in puzzle.squares.values() if sq.square_type == SquareType.LOCK
    )
    start = State(puzzle.player_start, initial_locks)

    queue: deque[tuple[State, list[str]]] = deque([(start, [])])
    visited: set[State] = {start}

    while queue:
        state, path = queue.popleft()

        for dir_name, (dr, dc) in DIRECTIONS.items():
            result = _simulate_move(puzzle, state, dr, dc)
            if result is None:
                continue

            new_state, reached_goal = result
            if reached_goal:
                return [*path, dir_name]
            if new_state not in visited:
                visited.add(new_state)
                queue.append((new_state, [*path, dir_name]))

    return None


def _simulate_move(
    puzzle: Puzzle,
    state: State,
    dr: int,
    dc: int,
) -> tuple[State, bool] | None:
    """Slide in (dr, dc).  Returns (new_state, is_goal) or None on death."""
    r, c = state.pos
    min_r, max_r, min_c, max_c = puzzle.grid_bounds
    approach_side = DIR_TO_APPROACH_SIDE[(dr, dc)]

    cr, cc = r + dr, c + dc
    while min_r <= cr <= max_r and min_c <= cc <= max_c:
        pos = (cr, cc)

        if pos in puzzle.squares:
            sq = puzzle.squares[pos]

            # Vanished lock - slide through
            if sq.square_type == SquareType.LOCK and pos not in state.active_locks:
                cr += dr
                cc += dc
                continue

            # Portal pass-through when approached from marked side
            if sq.square_type == SquareType.PORTAL and sq.portal_side == approach_side:
                cr += dr
                cc += dc
                continue

            # ── Landing ──
            if sq.square_type == SquareType.GOAL:
                return State(pos, state.active_locks), True

            if (
                sq.square_type == SquareType.TELEPORTER
                and pos in puzzle.teleporter_pairs
            ):
                return State(puzzle.teleporter_pairs[pos], state.active_locks), False

            if sq.square_type == SquareType.KEY and pos in puzzle.key_to_lock:
                lock_pos = puzzle.key_to_lock[pos]
                return State(pos, state.active_locks - {lock_pos}), False

            # Default: land on square
            return State(pos, state.active_locks), False

        cr += dr
        cc += dc

    return None  # off-grid → death


# ── Pretty-print ─────────────────────────────────────────────────────

_TYPE_CHAR = {
    SquareType.NORMAL: ".",
    SquareType.PLAYER: "P",
    SquareType.GOAL: "G",
    SquareType.PORTAL: "O",
    SquareType.TELEPORTER: "T",
    SquareType.KEY: "K",
    SquareType.LOCK: "L",
}


def print_puzzle(puzzle: Puzzle) -> None:
    """Print an ASCII representation of the puzzle grid."""
    min_r, max_r, min_c, max_c = puzzle.grid_bounds
    for r in range(min_r + 1, max_r):
        row_chars: list[str] = []
        for c in range(min_c + 1, max_c):
            if (r, c) in puzzle.squares:
                sq = puzzle.squares[(r, c)]
                ch = _TYPE_CHAR.get(sq.square_type, "?")
                if sq.square_type == SquareType.PORTAL and sq.portal_side:
                    arrow = {"left": "<", "right": ">", "up": "^", "down": "v"}
                    ch = arrow.get(sq.portal_side, "O")
                row_chars.append(ch)
            else:
                row_chars.append(" ")


def print_solution(puzzle: Puzzle, moves: list[str]) -> None:
    """Print the solution path step by step."""
    state = State(
        puzzle.player_start,
        frozenset(
            sq.pos
            for sq in puzzle.squares.values()
            if sq.square_type == SquareType.LOCK
        ),
    )
    for _i, move in enumerate(moves, 1):
        dr, dc = DIRECTIONS[move]
        result = _simulate_move(puzzle, state, dr, dc)
        if result is None:
            return
        state, _goal = result
